rename config window to settings and update CLI entry points

- Replace `--config`/`subminer config` (no action) with `--settings`/`subminer settings`
- `subminer config` now requires an explicit action (`path` or `show`)
- `--settings` previously opened Yomitan; replaced by `--yomitan`
- Linux tray update installs AppImage via electron-updater instead of manual flow
- macOS update dialog activation and curl-fetch routing fixes
- Delete stale compiled artifacts (main.js, app-updater.js)
This commit is contained in:
2026-05-20 20:31:02 -07:00
parent fcd6511aa1
commit 166015897d
63 changed files with 500 additions and 5281 deletions
+2 -2
View File
@@ -210,8 +210,8 @@ On **Windows**, just run `SubMiner.exe` — setup opens automatically on first l
```bash
subminer video.mkv # play video with overlay
subminer stats # open immersion dashboard
subminer config # open configuration window
subminer --config # open configuration window via flag
subminer settings # open settings window
subminer --settings # open settings window via flag
```
On **Windows**, use the **SubMiner mpv** shortcut created during setup — double-click it or drag a video file onto it.
-193
View File
@@ -1,193 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveMacAppBundlePath = resolveMacAppBundlePath;
exports.isMacApplicationsFolderBundle = isMacApplicationsFolderBundle;
exports.isKnownLinuxPackageManagedAppImage = isKnownLinuxPackageManagedAppImage;
exports.isNativeUpdaterSupported = isNativeUpdaterSupported;
exports.configureAutoUpdater = configureAutoUpdater;
exports.createElectronAppUpdater = createElectronAppUpdater;
const node_fs_1 = require("node:fs");
const node_child_process_1 = require("node:child_process");
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
const node_util_1 = require("node:util");
const electron_updater_1 = require("electron-updater");
const release_assets_1 = require("./release-assets");
const updaterErrorListeners = new WeakMap();
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
function resolveMacAppBundlePath(execPath) {
const marker = '.app/Contents/MacOS/';
const markerIndex = execPath.indexOf(marker);
if (markerIndex < 0)
return null;
return execPath.slice(0, markerIndex + '.app'.length);
}
async function readMacCodeSignature(appBundlePath) {
try {
const result = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], {
encoding: 'utf8',
});
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
}
catch {
return null;
}
}
function realpathOrOriginal(filePath) {
try {
return (0, node_fs_1.realpathSync)(filePath);
}
catch {
return filePath;
}
}
function isSameOrInsideDirectory(parentPath, candidatePath) {
const relative = node_path_1.default.relative(parentPath, candidatePath);
return (relative === '' ||
(relative.length > 0 && !relative.startsWith('..') && !node_path_1.default.isAbsolute(relative)));
}
function isMacApplicationsFolderBundle(appBundlePath, homeDir = node_os_1.default.homedir()) {
const resolvedBundlePath = node_path_1.default.resolve(appBundlePath);
return (isSameOrInsideDirectory('/Applications', resolvedBundlePath) ||
isSameOrInsideDirectory(node_path_1.default.join(homeDir, 'Applications'), resolvedBundlePath));
}
function isKnownLinuxPackageManagedAppImage(appImagePath) {
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
}
async function isNativeUpdaterSupported(options) {
if (!options.isPackaged) {
options.log?.('Skipping native updater because this build is not packaged.');
return false;
}
if (options.platform === 'linux') {
options.log?.('Skipping native Linux updater because Linux tray checks use GitHub release assets.');
return false;
}
if (options.platform !== 'darwin') {
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
return false;
}
const appBundlePath = resolveMacAppBundlePath(options.execPath);
if (!appBundlePath) {
options.log?.('Skipping native macOS updater because the app bundle path could not be resolved.');
return false;
}
if (!isMacApplicationsFolderBundle(appBundlePath, options.homeDir)) {
options.log?.('Skipping native macOS updater because the app is not installed in an Applications folder.');
return false;
}
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
if (!signature) {
options.log?.('Skipping native macOS updater because the app code signature could not be read.');
return false;
}
if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) {
options.log?.('Skipping native macOS updater because this build is ad-hoc signed.');
return false;
}
return true;
}
function configureAutoUpdater(updater, log = () => { }, channel = 'stable') {
updater.autoDownload = false;
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
updater.autoInstallOnAppQuit = false;
updater.allowPrerelease = channel === 'prerelease';
updater.allowDowngrade = false;
updater.logger = {
info: () => { },
debug: () => { },
warn: (message) => log(message),
error: (message) => log(message),
};
const previousErrorListener = updaterErrorListeners.get(updater);
if (previousErrorListener) {
if (updater.off) {
updater.off('error', previousErrorListener);
}
else {
updater.removeListener?.('error', previousErrorListener);
}
}
if (updater.on) {
const errorListener = (error) => {
const message = error instanceof Error ? error.message : String(error);
log(`Updater error event: ${message}`);
};
updater.on('error', errorListener);
updaterErrorListeners.set(updater, errorListener);
}
return updater;
}
function createElectronAppUpdater(options) {
const getChannel = options.getChannel ?? (() => 'stable');
const updater = configureAutoUpdater(options.updater ?? electron_updater_1.autoUpdater, options.log, getChannel());
if (options.configureHttpExecutor) {
// electron-updater has no public executor hook; keep the macOS cURL override localized.
updater.httpExecutor = options.configureHttpExecutor();
}
if (options.disableDifferentialDownload !== undefined) {
updater.disableDifferentialDownload = options.disableDifferentialDownload;
}
let nativeUpdaterSupported = null;
async function getNativeUpdaterSupported() {
if (!options.isNativeUpdaterSupported)
return true;
if (nativeUpdaterSupported === null) {
nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported());
}
return nativeUpdaterSupported;
}
return {
async checkForUpdates(channel) {
if (!options.isPackaged) {
return {
available: false,
version: options.currentVersion,
canUpdate: false,
};
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping native app update check because native updater is unsupported.');
return {
available: false,
version: options.currentVersion,
canUpdate: false,
};
}
configureAutoUpdater(updater, options.log, channel ?? getChannel());
const result = await updater.checkForUpdates();
const version = result?.updateInfo?.version ?? options.currentVersion;
return {
available: (0, release_assets_1.compareSemverLike)(version, options.currentVersion) > 0,
version,
canUpdate: true,
};
},
async downloadUpdate() {
if (!options.isPackaged) {
options.log('Skipping app update download because this build is not packaged.');
return;
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping app update download because native updater is unsupported.');
return;
}
await updater.downloadUpdate();
},
async quitAndInstall() {
if (!options.isPackaged) {
options.log('Skipping app update install because this build is not packaged.');
return;
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping app update install because native updater is unsupported.');
return;
}
updater.quitAndInstall(false, true);
},
};
}
//# sourceMappingURL=app-updater.js.map
+1 -1
View File
@@ -1,4 +1,4 @@
type: changed
area: config
- Reorganized the Configuration window into clearer Appearance, Behavior, Anki, input, and integration sections with learned keybinding controls and AnkiConnect-backed deck, field, and note-type pickers.
- Reorganized the Settings window into clearer Appearance, Behavior, Anki, input, and integration sections with learned keybinding controls and AnkiConnect-backed deck, field, and note-type pickers.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed
area: config
- Fixed Configuration window search so it searches across all categories, narrows on multi-word terms, hides settings owned by richer editors, and no longer shows the Open File button.
- Fixed Settings window search so it searches across all categories, narrows on multi-word terms, hides settings owned by richer editors, and no longer shows the Open File button.
+5 -5
View File
@@ -1,8 +1,8 @@
type: added
area: config
- Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer config`.
- Fixed the Configuration window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
- Kept config-window startup lightweight by skipping AniList token refresh and automatic update polling.
- Marked safe live config options in the Configuration window and expanded hot reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, Anki media/sentence/misc field mappings, sentence card model, and selected Anki annotation/runtime options.
- Hid AI and translation fields from the Configuration window while keeping them supported in config files.
- Added a dedicated Settings window with launcher entry points via `subminer --settings` and `subminer settings`.
- Fixed the Settings window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
- Kept settings-window startup lightweight by skipping AniList token refresh and automatic update polling.
- Marked safe live config options in the Settings window and expanded hot reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, Anki media/sentence/misc field mappings, sentence card model, and selected Anki annotation/runtime options.
- Hid AI and translation fields from the Settings window while keeping them supported in config files.
@@ -0,0 +1,4 @@
type: changed
area: config
- Reorganized each known-words deck row in the Settings window into a card with the deck name on its own header line so longer deck names stay readable instead of being truncated.
@@ -1,4 +1,4 @@
type: fixed
area: launcher
- Suppressed Electron macOS menu diagnostics from `subminer config` launcher output.
- Suppressed Electron macOS menu diagnostics from `subminer settings` launcher output.
+5
View File
@@ -0,0 +1,5 @@
type: changed
area: updater
- Linux tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows tray flow, instead of stopping at a "manual update required" dialog. AppImages managed by a system package (AUR `/opt/SubMiner/SubMiner.AppImage`) and non-AppImage launches (no `APPIMAGE` env) still fall back to the GitHub-asset flow.
- Routed `electron-updater` HTTP through `/usr/bin/curl` on Linux and disabled differential downloads, matching the macOS path, so background update checks stay off Electron's network service.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- macOS `subminer settings` launches now exit cleanly after the settings window is closed, returning control to the terminal without requiring Ctrl+C.
@@ -0,0 +1,4 @@
type: fixed
area: updater
- macOS update dialogs triggered by `subminer -u` now reliably appear in the foreground. SubMiner now shows the dock icon and activates itself via `osascript` (LaunchServices) before opening the modal alert; `app.focus({ steal: true })` alone was unreliable when SubMiner was reached through single-instance forwarding from the CLI-spawned child, leaving the dialog stranded behind other apps with a bouncing dock icon.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Routed macOS supplemental GitHub release lookups through `/usr/bin/curl` instead of Electron `net.fetch`, eliminating the last Electron-networking path from background update checks and avoiding the network-service crashes seen in earlier prereleases.
@@ -0,0 +1,7 @@
type: changed
area: launcher
breaking: true
- Renamed the SubMiner Configuration window to the Settings window across the UI, tray menu, docs, and CLI verbiage.
- Replaced the `--config` flag and `subminer config` (no action) entry points with `--settings` and `subminer settings`. The `subminer config` subcommand now only accepts `path` or `show`.
- Removed the `--settings` alias that previously opened the bundled Yomitan settings popup. Use `--yomitan` to open Yomitan settings.
+1
View File
@@ -0,0 +1 @@
- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed
area: config
- Fixed live Configuration window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
- Fixed live Settings window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
+1 -1
View File
@@ -37,7 +37,7 @@ Then customize as needed using the sections below.
## Settings
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It is the primary way to configure SubMiner — all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--settings` flag, or launcher commands such as `subminer --settings` and `subminer settings`. It is the primary way to configure SubMiner — all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
+2 -2
View File
@@ -300,9 +300,9 @@ subminer --update
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
On Linux, `subminer -u` performs the AppImage update from the launcher process directly.
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
On macOS, tray update checks can also update the app automatically through Electron's built-in updater.
`subminer -u` also performs the AppImage update directly from the launcher process, which is useful when SubMiner is not currently running.
## How It All Fits Together
+1
View File
@@ -77,6 +77,7 @@ subminer stats -b # start background stats daemon
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
| `subminer doctor` | Dependency + config + socket diagnostics |
| `subminer settings` | Open the SubMiner settings window |
| `subminer config path` | Print active config file path |
| `subminer config show` | Print active config contents |
| `subminer mpv status` | Check mpv socket readiness |
+1 -1
View File
@@ -205,7 +205,7 @@ If you installed from the AppImage and see this error, the package may be incomp
**Yomitan lookup popup does not appear when hovering words or triggering lookup**
- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension".
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported.
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --yomitan`) and confirm at least one dictionary is imported.
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
- If the overlay shows subtitles but hover lookup never resolves on tokens, the tokenizer may have failed. See the MeCab section below.
+5 -3
View File
@@ -131,7 +131,8 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle
SubMiner.AppImage --start --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
SubMiner.AppImage --settings # Open Yomitan settings
SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open SubMiner settings window
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret'
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
@@ -184,7 +185,8 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
- `subminer doctor`: health checks for core dependencies and runtime paths.
- `subminer config`: config helpers (`path`, `show`).
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
- `subminer config`: config file helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
@@ -264,7 +266,7 @@ secondary-sub-visibility=no
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --settings` or `SubMiner.AppImage --settings`) and import at least one dictionary in the bundled Yomitan instance.
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --yomitan` or `SubMiner.AppImage --yomitan`) and import at least one dictionary in the bundled Yomitan instance.
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
+2 -2
View File
@@ -6,8 +6,8 @@ export function runAppPassthroughCommand(context: LauncherCommandContext): boole
if (!appPath) {
return false;
}
if (args.configSettings) {
runAppCommandWithInherit(appPath, ['--config']);
if (args.settings) {
runAppCommandWithInherit(appPath, ['--settings']);
return true;
}
if (!args.appPassthrough) {
+1 -1
View File
@@ -53,7 +53,7 @@ function createContext(): LauncherCommandContext {
doctor: false,
doctorRefreshKnownWords: false,
version: false,
configSettings: false,
settings: false,
configPath: false,
configShow: false,
mpvIdle: false,
+44 -4
View File
@@ -124,6 +124,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
action: 'show',
logLevel: 'warn',
},
settingsInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -159,13 +160,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
assert.equal(parsed.logLevel, 'warn');
});
test('applyInvocationsToArgs maps bare config invocation to settings window', () => {
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: {
action: undefined,
configInvocation: null,
settingsInvocation: {
logLevel: undefined,
},
mpvInvocation: null,
appInvocation: null,
@@ -190,16 +192,54 @@ test('applyInvocationsToArgs maps bare config invocation to settings window', ()
texthookerOpenBrowser: false,
});
assert.equal(parsed.configSettings, true);
assert.equal(parsed.settings, true);
assert.equal(parsed.configPath, false);
});
test('applyInvocationsToArgs fails when config invocation has no action', () => {
const parsed = createDefaultArgs({});
const error = withProcessExitIntercept(() => {
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: {
action: undefined,
},
settingsInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
});
});
assert.equal(error.code, 1);
});
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
+11 -5
View File
@@ -158,7 +158,7 @@ export function createDefaultArgs(
doctorRefreshKnownWords: false,
version: false,
update: false,
configSettings: false,
settings: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -222,7 +222,7 @@ export function applyRootOptionsToArgs(
if (options.rofi === true) parsed.useRofi = true;
if (options.update === true) parsed.update = true;
if (options.version === true) parsed.version = true;
if (options.config === true) parsed.configSettings = true;
if (options.settings === true) parsed.settings = true;
if (options.startOverlay === true) parsed.autoStartOverlay = true;
if (options.texthooker === false) parsed.useTexthooker = false;
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
@@ -311,10 +311,16 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
}
const action = (invocations.configInvocation.action || '').toLowerCase();
if (!action) parsed.configSettings = true;
else if (action === 'path') parsed.configPath = true;
if (action === 'path') parsed.configPath = true;
else if (action === 'show') parsed.configShow = true;
else fail(`Unknown config action: ${invocations.configInvocation.action}`);
else fail(`Unknown config action: ${invocations.configInvocation.action || '(none)'}. Expected path or show.`);
}
if (invocations.settingsInvocation) {
if (invocations.settingsInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
}
parsed.settings = true;
}
if (invocations.mpvInvocation) {
+16 -2
View File
@@ -22,6 +22,7 @@ export interface CommandActionInvocation {
export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null;
configInvocation: CommandActionInvocation | null;
settingsInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null;
dictionaryTriggered: boolean;
@@ -58,7 +59,7 @@ function applyRootOptions(program: Command): void {
.option('--start', 'Explicitly start overlay')
.option('--log-level <level>', 'Log level')
.option('-v, --version', 'Show SubMiner version')
.option('--config', 'Open configuration window')
.option('--settings', 'Open settings window')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-S, --start-overlay', 'Auto-start overlay')
@@ -88,6 +89,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'jf',
'doctor',
'config',
'settings',
'mpv',
'dictionary',
'dict',
@@ -138,6 +140,7 @@ export function parseCliPrograms(
} {
let jellyfinInvocation: JellyfinInvocation | null = null;
let configInvocation: CommandActionInvocation | null = null;
let settingsInvocation: CommandActionInvocation | null = null;
let mpvInvocation: CommandActionInvocation | null = null;
let appInvocation: { appArgs: string[] } | null = null;
let dictionaryTriggered = false;
@@ -293,7 +296,7 @@ export function parseCliPrograms(
commandProgram
.command('config')
.description('Config helpers')
.description('Config file helpers (path|show)')
.argument('[action]', 'path|show')
.option('--log-level <level>', 'Log level')
.action((action: string | undefined, options: Record<string, unknown>) => {
@@ -303,6 +306,16 @@ export function parseCliPrograms(
};
});
commandProgram
.command('settings')
.description('Open SubMiner settings window')
.option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => {
settingsInvocation = {
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram
.command('mpv')
.description('MPV helpers')
@@ -356,6 +369,7 @@ export function parseCliPrograms(
invocations: {
jellyfinInvocation,
configInvocation,
settingsInvocation,
mpvInvocation,
appInvocation,
dictionaryTriggered,
+8 -8
View File
@@ -232,7 +232,7 @@ test('doctor refresh-known-words forwards app refresh command without requiring
});
});
test('launcher config option forwards app configuration window command', () => {
test('launcher settings option forwards app settings window command', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -249,14 +249,14 @@ test('launcher config option forwards app configuration window command', () => {
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['--config'], env);
const result = runLauncher(['--settings'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
});
});
test('launcher config command forwards app configuration window command', () => {
test('launcher settings command forwards app settings window command', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -273,14 +273,14 @@ test('launcher config command forwards app configuration window command', () =>
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['config'], env);
const result = runLauncher(['settings'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
});
});
test('launcher config command suppresses known Electron macOS menu diagnostics', () => {
test('launcher settings command suppresses known Electron macOS menu diagnostics', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -301,7 +301,7 @@ test('launcher config command suppresses known Electron macOS menu diagnostics',
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
};
const result = runLauncher(['config'], env);
const result = runLauncher(['settings'], env);
assert.equal(result.status, 0);
assert.equal(result.stderr, 'real stderr line\n');
+1 -1
View File
@@ -569,7 +569,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
doctor: false,
doctorRefreshKnownWords: false,
version: false,
configSettings: false,
settings: false,
configPath: false,
configShow: false,
mpvIdle: false,
+15 -7
View File
@@ -57,10 +57,10 @@ test('parseArgs captures mpv args string', () => {
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
});
test('parseArgs maps root config window option', () => {
const parsed = parseArgs(['--config'], 'subminer', {});
test('parseArgs maps root settings window option', () => {
const parsed = parseArgs(['--settings'], 'subminer', {});
assert.equal(parsed.configSettings, true);
assert.equal(parsed.settings, true);
});
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
@@ -107,10 +107,10 @@ test('parseArgs maps config show action', () => {
assert.equal(parsed.configPath, false);
});
test('parseArgs maps bare config command to settings window', () => {
const parsed = parseArgs(['config'], 'subminer', {});
test('parseArgs maps settings command to settings window', () => {
const parsed = parseArgs(['settings'], 'subminer', {});
assert.equal(parsed.configSettings, true);
assert.equal(parsed.settings, true);
assert.equal(parsed.configPath, false);
assert.equal(parsed.configShow, false);
});
@@ -119,7 +119,7 @@ test('parseArgs maps config path action to config path output', () => {
const parsed = parseArgs(['config', 'path'], 'subminer', {});
assert.equal(parsed.configPath, true);
assert.equal(parsed.configSettings, false);
assert.equal(parsed.settings, false);
});
test('parseArgs rejects removed config open and launch actions', () => {
@@ -134,6 +134,14 @@ test('parseArgs rejects removed config open and launch actions', () => {
assert.equal(exit.code, 1);
});
test('parseArgs requires an explicit action for the config subcommand', () => {
const exit = withProcessExitIntercept(() => {
parseArgs(['config'], 'subminer', {});
});
assert.equal(exit.code, 1);
});
test('parseArgs maps mpv idle action', () => {
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
+1 -1
View File
@@ -133,7 +133,7 @@ export interface Args {
doctorRefreshKnownWords: boolean;
version: boolean;
update?: boolean;
configSettings: boolean;
settings: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
-4711
View File
File diff suppressed because it is too large Load Diff
+18 -20
View File
@@ -7,7 +7,7 @@ import {
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldRunYomitanOnlyStartup,
shouldStartApp,
} from './args';
@@ -66,7 +66,7 @@ test('parseArgs captures update command and internal launcher paths', () => {
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(shouldRunSettingsOnlyStartup(args), false);
assert.equal(shouldRunYomitanOnlyStartup(args), false);
});
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
@@ -208,35 +208,33 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(update), true);
assert.equal(isHeadlessInitialCommand(update), true);
const yomitan = parseArgs(['--yomitan']);
assert.equal(yomitan.yomitan, true);
assert.equal(hasExplicitCommand(yomitan), true);
assert.equal(shouldStartApp(yomitan), true);
assert.equal(shouldRunYomitanOnlyStartup(yomitan), true);
const settings = parseArgs(['--settings']);
assert.equal(settings.settings, true);
assert.equal(hasExplicitCommand(settings), true);
assert.equal(shouldStartApp(settings), true);
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
assert.equal(shouldRunYomitanOnlyStartup(settings), false);
assert.equal(commandNeedsOverlayRuntime(settings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(settings), false);
const configSettings = parseArgs(['--config']);
assert.equal(configSettings.configSettings, true);
assert.equal(hasExplicitCommand(configSettings), true);
assert.equal(shouldStartApp(configSettings), true);
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
assert.equal(yomitanWithOverlay.yomitan, true);
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
assert.equal(shouldRunYomitanOnlyStartup(yomitanWithOverlay), false);
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
assert.equal(settingsWithOverlay.settings, true);
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
assert.equal(shouldRunSettingsOnlyStartup(settingsWithOverlay), false);
const yomitanAlias = parseArgs(['--yomitan']);
assert.equal(yomitanAlias.settings, true);
assert.equal(hasExplicitCommand(yomitanAlias), true);
assert.equal(shouldStartApp(yomitanAlias), true);
const settingsDoesNotEnableYomitan = parseArgs(['--settings']);
assert.equal(settingsDoesNotEnableYomitan.yomitan, false);
const help = parseArgs(['--help']);
assert.equal(help.help, true);
assert.equal(hasExplicitCommand(help), true);
assert.equal(shouldStartApp(help), false);
assert.equal(shouldRunSettingsOnlyStartup(help), false);
assert.equal(shouldRunYomitanOnlyStartup(help), false);
const appPing = parseArgs(['--app-ping']);
assert.equal(appPing.appPing, true);
+10 -10
View File
@@ -10,8 +10,8 @@ export interface CliArgs {
toggle: boolean;
toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
yomitan: boolean;
settings: boolean;
configSettings: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -117,8 +117,8 @@ export function parseArgs(argv: string[]): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -239,8 +239,8 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--config') args.configSettings = true;
else if (arg === '--yomitan') args.yomitan = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -494,8 +494,8 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.setup ||
args.show ||
args.hide ||
@@ -569,8 +569,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.yomitan &&
!args.settings &&
!args.configSettings &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -639,8 +639,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -687,16 +687,16 @@ export function shouldStartApp(args: CliArgs): boolean {
return false;
}
export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
return (
args.settings &&
args.yomitan &&
!args.background &&
!args.start &&
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.configSettings &&
!args.settings &&
!args.show &&
!args.hide &&
!args.setup &&
+2 -1
View File
@@ -22,7 +22,8 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
assert.doesNotMatch(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--config\s+Open configuration window/);
assert.match(output, /--settings\s+Open SubMiner settings window/);
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
+2 -2
View File
@@ -24,8 +24,8 @@ ${B}Overlay${R}
--toggle-primary-subtitle-bar Toggle primary subtitle bar
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--settings Open Yomitan settings window
--config Open configuration window
--yomitan Open Yomitan settings window
--settings Open SubMiner settings window
--setup Open first-run setup window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
+63 -2
View File
@@ -19,17 +19,77 @@ test('settings registry splits viewing into appearance and behavior categories',
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
assert.equal(field('auto_start_overlay').category, 'behavior');
assert.equal(field('auto_start_overlay').section, 'Visible Overlay Auto-Start');
assert.equal(field('auto_start_overlay').section, 'Playback Behavior');
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
assert.equal(field('mpv.launchMode').category, 'behavior');
assert.equal(field('mpv.launchMode').section, 'MPV Launcher');
assert.equal(field('mpv.launchMode').section, 'mpv Playback');
assert.ok(
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
);
});
test('settings registry groups playback startup controls under playback behavior', () => {
for (const path of [
'subtitleStyle.autoPauseVideoOnHover',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'subtitleSidebar.pauseVideoOnHover',
'mpv.autoStartSubMiner',
'auto_start_overlay',
'mpv.pauseUntilOverlayReady',
]) {
assert.equal(field(path).category, 'behavior', path);
assert.equal(field(path).section, 'Playback Behavior', path);
}
});
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback');
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
});
test('settings registry hides removed modal-only fields', () => {
for (const path of [
'shortcuts.multiCopyTimeoutMs',
'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
]) {
assert.equal(
fields.some((candidate) => candidate.configPath === path),
false,
path,
);
}
});
test('settings registry orders websocket server immediately after annotation websocket', () => {
const integrationSections = [
...new Set(
fields
.filter((candidate) => candidate.category === 'integrations')
.map((candidate) => candidate.section),
),
];
const annotationIndex = integrationSections.indexOf('Annotation WebSocket');
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
});
test('settings registry places immersion tracking after other tracking and app sections', () => {
const trackingSections = [
...new Set(
fields
.filter((candidate) => candidate.category === 'tracking-app')
.map((candidate) => candidate.section),
),
];
assert.equal(trackingSections.at(-1), 'Immersion tracking');
});
test('settings registry groups annotation display fields by config group', () => {
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
@@ -190,6 +250,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
test('settings registry marks safe live config paths as hot-reloadable', () => {
for (const path of [
'mpv.aniskipButtonKey',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
+38 -11
View File
@@ -65,13 +65,17 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.profileScope',
'jellyfin.accessToken',
'jellyfin.userId',
'jellyfin.clientName',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
'controller.buttonIndices',
'shortcuts.multiCopyTimeoutMs',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
] as const;
@@ -123,12 +127,11 @@ const SECTION_ORDER = new Map<string, number>(
'Primary Subtitle Appearance',
'Secondary Subtitle Appearance',
'Subtitle Sidebar Appearance',
'Playback Pause Behavior',
'Playback Behavior',
'Subtitle Behavior',
'Subtitle Sidebar Behavior',
'Visible Overlay Auto-Start',
'YouTube Playback Settings',
'MPV Launcher',
'mpv Playback',
'Note Fields',
'Media Capture',
'Kiku/Lapis Features',
@@ -140,7 +143,19 @@ const SECTION_ORDER = new Map<string, number>(
'MPV Keybindings',
'Overlay Shortcuts',
'Controller',
'Annotation WebSocket',
'WebSocket server',
'AniList',
'Character Dictionary',
'Discord Rich Presence',
'Jellyfin',
'Texthooker',
'Yomitan',
'Stats dashboard',
'Startup warmups',
'Logging',
'Updates',
'Immersion tracking',
].map((section, index) => [section, index]),
);
@@ -169,9 +184,9 @@ const PATH_ORDER = new Map<string, number>(
'mpv.backend',
'mpv.subminerBinaryPath',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'mpv.launchMode',
'mpv.executablePath',
'mpv.aniskipButtonKey',
].map((path, index) => [path, index]),
);
@@ -186,7 +201,6 @@ const SUBSECTION_ORDER = new Map<string, number>(
'Toggle & Visibility',
'Open Panels',
'Playback',
'Timing',
'Default Fold State',
].map((subsection, index) => [subsection, index]),
);
@@ -215,6 +229,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
'mpv.aniskipEnabled': 'Enable AniSkip',
'mpv.aniskipButtonKey': 'AniSkip Button Key',
'discordPresence.updateIntervalMs': 'Update Interval Seconds',
};
const DESCRIPTION_OVERRIDES: Record<string, string> = {
@@ -232,6 +247,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
'discordPresence.updateIntervalMs':
'Minimum interval between presence payload updates, in seconds.',
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -295,7 +312,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'behavior', section: 'Playback Pause Behavior' };
return { category: 'behavior', section: 'Playback Behavior' };
}
if (path === 'subtitleStyle.preserveLineBreaks') {
return { category: 'behavior', section: 'Subtitle Behavior' };
@@ -373,8 +390,15 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('ankiConnect.')) {
return { category: 'mining-anki', section: 'AnkiConnect' };
}
if (path === 'auto_start_overlay') {
return { category: 'behavior', section: topSection(path) };
if (
path === 'auto_start_overlay' ||
path === 'mpv.autoStartSubMiner' ||
path === 'mpv.pauseUntilOverlayReady'
) {
return { category: 'behavior', section: 'Playback Behavior' };
}
if (path === 'mpv.aniskipButtonKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
@@ -437,7 +461,7 @@ function topSection(path: string): string {
jimaku: 'Jimaku',
jellyfin: 'Jellyfin',
logging: 'Logging',
mpv: 'MPV Launcher',
mpv: 'mpv Playback',
stats: 'Stats dashboard',
startupWarmups: 'Startup warmups',
subsync: 'Subtitle Sync',
@@ -447,7 +471,7 @@ function topSection(path: string): string {
yomitan: 'Yomitan',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Visible Overlay Auto-Start',
auto_start_overlay: 'Playback Behavior',
};
return labels[top] ?? humanizePath(top);
}
@@ -515,9 +539,11 @@ function subsectionForPath(path: string): string | undefined {
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return 'Toggle & Visibility';
}
if (path === 'mpv.aniskipButtonKey') {
return 'Playback';
}
if (path.startsWith('shortcuts.')) {
const leaf = path.split('.').at(-1) ?? '';
if (leaf === 'multiCopyTimeoutMs') return 'Timing';
if (
leaf === 'copySubtitle' ||
leaf === 'copySubtitleMultiple' ||
@@ -632,6 +658,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'mpv.aniskipButtonKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
+20 -1
View File
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -223,3 +223,22 @@ test('startAppLifecycle queues second-instance commands until app ready runtime
runSecondInstance(['SubMiner', '--start']);
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
});
test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
isDarwinPlatform: () => true,
shouldQuitOnWindowAllClosed: () => true,
onWindowAllClosed: (handler) => {
windowAllClosedHandler = handler;
},
});
startAppLifecycle(makeArgs({ settings: true }), deps);
const handler = windowAllClosedHandler as (() => void) | null;
assert.ok(handler);
handler();
assert.deepEqual(calls, ['quitApp']);
});
+4 -1
View File
@@ -164,7 +164,10 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
});
deps.onWindowAllClosed(() => {
if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) {
if (
deps.shouldQuitOnWindowAllClosed() &&
(!deps.isDarwinPlatform() || initialArgs.settings)
) {
deps.quitApp();
}
});
+3 -3
View File
@@ -15,8 +15,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false,
toggle: false,
toggleVisibleOverlay: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -586,8 +586,8 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
args: Partial<CliArgs>;
expected: string;
}> = [
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
{
args: { showVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:true',
+2 -2
View File
@@ -386,9 +386,9 @@ export function handleCliCommand(
} else if (args.setup) {
deps.openFirstRunSetup(true);
deps.logDebug('Opened first-run setup flow.');
} else if (args.settings) {
} else if (args.yomitan) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.configSettings) {
} else if (args.settings) {
deps.openConfigSettingsWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
@@ -21,6 +21,7 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', ()
test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
const prev = deepCloneConfig(DEFAULT_CONFIG);
const next = deepCloneConfig(DEFAULT_CONFIG);
next.mpv.aniskipButtonKey = 'F8';
next.stats.toggleKey = 'F8';
next.stats.markWatchedKey = 'F9';
next.logging.level = 'debug';
@@ -52,6 +53,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
new Set(diff.hotReloadFields),
new Set([
'stats.toggleKey',
'mpv.aniskipButtonKey',
'stats.markWatchedKey',
'logging.level',
'youtube.primarySubLanguages',
+1
View File
@@ -56,6 +56,7 @@ const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitle
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode',
'mpv.aniskipButtonKey',
'ankiConnect.ai.enabled',
'stats.toggleKey',
'stats.markWatchedKey',
+1 -1
View File
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
+39 -25
View File
@@ -21,7 +21,6 @@ import {
clipboard,
globalShortcut,
ipcMain,
net,
shell,
protocol,
Extension,
@@ -91,7 +90,7 @@ protocol.registerSchemesAsPrivileged([
]);
import * as fs from 'fs';
import { spawn } from 'node:child_process';
import { execFile, spawn } from 'node:child_process';
import * as os from 'os';
import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer';
@@ -505,11 +504,7 @@ import {
createElectronAppUpdater,
isNativeUpdaterSupported,
} from './main/runtime/update/app-updater';
import {
createCurlFetch,
createElectronNetFetch,
createGlobalFetch,
} from './main/runtime/update/fetch-adapter';
import { createCurlFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter';
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
import {
@@ -618,6 +613,7 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const TRAY_TOOLTIP = 'SubMiner';
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
@@ -4894,28 +4890,19 @@ flushPendingMpvLogWrites = () => {
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
let updateService: ReturnType<typeof createUpdateService> | null = null;
const electronNetFetch = createElectronNetFetch({
fetch: (url, init) => net.fetch(url, init as RequestInit),
});
const globalFetchForUpdater = createGlobalFetch();
const curlFetch = createCurlFetch();
function createNativeUpdaterHttpExecutor() {
if (process.platform === 'darwin') {
return createCurlHttpExecutor();
}
if (process.platform === 'win32') {
return createFetchHttpExecutor();
}
return undefined;
return createCurlHttpExecutor();
}
function getFetchForUpdater() {
if (process.platform === 'win32') {
return globalFetchForUpdater;
}
if (process.platform === 'linux') return curlFetch;
return electronNetFetch;
if (process.platform === 'win32') return globalFetchForUpdater;
return curlFetch;
}
async function updateLauncherFromSelectedRelease(
@@ -4962,11 +4949,8 @@ function getUpdateService() {
isPackaged: app.isPackaged,
log: (message) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel,
configureHttpExecutor:
process.platform === 'darwin' || process.platform === 'win32'
? createNativeUpdaterHttpExecutor
: undefined,
disableDifferentialDownload: process.platform === 'darwin' || process.platform === 'win32',
configureHttpExecutor: createNativeUpdaterHttpExecutor,
disableDifferentialDownload: true,
isNativeUpdaterSupported: () =>
isNativeUpdaterSupported({
platform: process.platform,
@@ -4978,7 +4962,37 @@ function getUpdateService() {
});
const updateDialogPresenter = createUpdateDialogPresenter({
platform: process.platform,
focusApp: () => app.focus({ steal: true }),
focusApp: async () => {
if (process.platform !== 'darwin') {
app.focus({ steal: true });
return;
}
try {
await app.dock?.show();
} catch (error) {
logger.warn('Failed to show macOS dock before update dialog', error);
}
// app.focus({ steal: true }) alone does not reliably activate the process
// when SubMiner was reached via `subminer -u` (single-instance forwarding
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
// which is the only path that reliably brings the running app forward.
await new Promise<void>((resolve) => {
execFile(
'/usr/bin/osascript',
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
{ timeout: 2000 },
(error) => {
if (error) {
logger.warn(
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
);
}
resolve();
},
);
});
app.focus({ steal: true });
},
showMessageBox: (options) => dialog.showMessageBox(options),
});
updateService = createUpdateService({
+1 -1
View File
@@ -10,7 +10,7 @@ const fields: ConfigSettingsField[] = [
description: 'Launch mode setting.',
configPath: 'mpv.launchMode',
category: 'behavior',
section: 'MPV Launcher',
section: 'mpv Playback',
control: 'select',
defaultValue: 'windowed',
restartBehavior: 'restart',
+1 -1
View File
@@ -27,7 +27,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
const window = deps.createSettingsWindow();
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
deps.log?.(`Failed to load configuration settings window: ${message}`);
deps.log?.(`Failed to load settings window: ${message}`);
deps.setSettingsWindow(null);
window.destroy?.();
});
@@ -29,8 +29,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -122,12 +122,12 @@ function createCommandLineLauncherSnapshot(
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, configSettings: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, settings: true })), false);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
false,
);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ yomitan: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
});
+1 -1
View File
@@ -71,8 +71,8 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.launchMpv ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
@@ -110,7 +110,7 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind
assert.deepEqual(options, {
width: 1040,
height: 760,
title: 'SubMiner Configuration',
title: 'SubMiner Settings',
show: true,
autoHideMenuBar: true,
resizable: true,
+1 -1
View File
@@ -76,7 +76,7 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
return createSetupWindowHandler(deps, {
width: 1040,
height: 760,
title: 'SubMiner Configuration',
title: 'SubMiner Settings',
resizable: true,
preloadPath: deps.preloadPath,
backgroundColor: '#24273a',
+2 -2
View File
@@ -7,8 +7,8 @@ import {
shouldStartAutomaticUpdateChecks,
} from './startup-mode-flags';
test('config settings startup uses minimal startup and skips background integrations', () => {
const args = parseArgs(['--config']);
test('settings window startup uses minimal startup and skips background integrations', () => {
const args = parseArgs(['--settings']);
const flags = getStartupModeFlags(args);
assert.equal(flags.shouldUseMinimalStartup, true);
+6 -6
View File
@@ -2,7 +2,7 @@ import type { CliArgs } from '../../cli/args';
import {
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
shouldRunSettingsOnlyStartup,
shouldRunYomitanOnlyStartup,
} from '../../cli/args';
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
@@ -12,15 +12,15 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
return {
shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
initialArgs?.configSettings ||
initialArgs?.settings ||
initialArgs?.update ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
shouldSkipHeavyStartup: Boolean(
initialArgs &&
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.configSettings ||
(shouldRunYomitanOnlyStartup(initialArgs) ||
initialArgs.settings ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.update ||
@@ -32,9 +32,9 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
export function shouldRefreshAnilistOnConfigReload(
initialArgs: CliArgs | null | undefined,
): boolean {
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.settings));
}
export function shouldStartAutomaticUpdateChecks(initialArgs: CliArgs | null | undefined): boolean {
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.settings));
}
+1 -1
View File
@@ -94,7 +94,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
click: handlers.openRuntimeOptions,
},
{
label: 'Open Configuration',
label: 'Open Settings',
click: handlers.openConfigSettings,
},
{
+5 -25
View File
@@ -258,7 +258,7 @@ test('mac native updater supports Developer ID signed packaged app bundles', asy
assert.deepEqual(logged, []);
});
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
test('linux native updater is supported for direct AppImage installs', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
@@ -270,10 +270,8 @@ test('linux native updater is unsupported even for writable direct AppImage inst
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
assert.equal(supported, true);
assert.deepEqual(logged, []);
});
test('linux native updater is unsupported when APPIMAGE is missing', async () => {
@@ -288,25 +286,7 @@ test('linux native updater is unsupported when APPIMAGE is missing', async () =>
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
});
test('linux native updater is unsupported for non-writable AppImage installs', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
isPackaged: true,
execPath: '/tmp/.mount_SubMiner/SubMiner',
env: {
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
'Skipping native Linux updater because APPIMAGE is not set (not launched from an AppImage).',
]);
});
@@ -324,7 +304,7 @@ test('linux native updater is unsupported for package-managed AppImage installs'
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
'Skipping native Linux updater because the AppImage is managed by a system package.',
]);
});
+16 -6
View File
@@ -108,15 +108,25 @@ export async function isNativeUpdaterSupported(options: {
options.log?.('Skipping native updater because this build is not packaged.');
return false;
}
if (options.platform === 'linux') {
options.log?.(
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
);
return false;
}
if (options.platform === 'win32') {
return true;
}
if (options.platform === 'linux') {
const appImagePath = options.env?.APPIMAGE;
if (!appImagePath) {
options.log?.(
'Skipping native Linux updater because APPIMAGE is not set (not launched from an AppImage).',
);
return false;
}
if (isKnownLinuxPackageManagedAppImage(appImagePath)) {
options.log?.(
'Skipping native Linux updater because the AppImage is managed by a system package.',
);
return false;
}
return true;
}
if (options.platform !== 'darwin') {
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
return false;
+38 -5
View File
@@ -6,7 +6,7 @@ import {
type ShowMessageBox,
} from './update-dialogs';
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
test('update dialog presenter focuses app and yields the run loop before showing macOS dialogs', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
@@ -14,16 +14,44 @@ test('update dialog presenter focuses app before showing macOS dialogs', async (
};
const presenter = createUpdateDialogPresenter({
platform: 'darwin',
focusApp: () => calls.push('focus'),
focusApp: () => {
calls.push('focus');
},
yieldToRunLoop: async () => {
calls.push('yield');
},
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, ['focus', 'dialog:SubMiner is up to date (v0.14.0)']);
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
});
test('update dialog presenter does not focus app before showing non-macOS dialogs', async () => {
test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
return { response: 0 };
};
const presenter = createUpdateDialogPresenter({
platform: 'darwin',
focusApp: async () => {
await new Promise<void>((resolve) => setImmediate(resolve));
calls.push('focus');
},
yieldToRunLoop: async () => {
calls.push('yield');
},
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
});
test('update dialog presenter does not focus app or yield before showing non-macOS dialogs', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
@@ -31,7 +59,12 @@ test('update dialog presenter does not focus app before showing non-macOS dialog
};
const presenter = createUpdateDialogPresenter({
platform: 'linux',
focusApp: () => calls.push('focus'),
focusApp: () => {
calls.push('focus');
},
yieldToRunLoop: async () => {
calls.push('yield');
},
showMessageBox,
});
+10 -4
View File
@@ -17,7 +17,8 @@ export type ShowMessageBox = (options: {
export interface UpdateDialogPresenterDeps {
showMessageBox: ShowMessageBox;
focusApp?: () => void;
focusApp?: () => void | Promise<void>;
yieldToRunLoop?: () => Promise<void>;
platform?: NodeJS.Platform;
}
@@ -33,14 +34,19 @@ export async function showNoUpdateDialog(
});
}
function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): void {
async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<void> {
if ((deps.platform ?? process.platform) !== 'darwin') return;
deps.focusApp?.();
await deps.focusApp?.();
// Yield to the macOS run loop so the activation request is processed before the
// modal alert blocks JS execution; without this, the alert often appears behind
// other apps when SubMiner is not the active app at dialog-show time.
const yieldToRunLoop = deps.yieldToRunLoop ?? (() => new Promise((r) => setTimeout(r, 0)));
await yieldToRunLoop();
}
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
const showFocusedMessageBox: ShowMessageBox = async (options) => {
maybeFocusAppForDialog(deps);
await maybeFocusAppForDialog(deps);
return deps.showMessageBox(options);
};
+4 -4
View File
@@ -7,22 +7,22 @@
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self';"
/>
<title>SubMiner Configuration</title>
<title>SubMiner Settings</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main id="app" class="settings-shell">
<aside class="settings-nav" aria-label="Configuration categories">
<aside class="settings-nav" aria-label="Settings categories">
<div class="brand-block">
<div class="brand-title">SubMiner</div>
<div class="brand-subtitle">Configuration</div>
<div class="brand-subtitle">Settings</div>
</div>
<nav id="categoryNav" class="category-nav"></nav>
</aside>
<section class="settings-main">
<header class="settings-toolbar">
<div class="toolbar-title-block">
<h1 id="categoryTitle">Configuration</h1>
<h1 id="categoryTitle">Settings</h1>
<div id="categoryMeta" class="toolbar-meta"></div>
</div>
<div class="toolbar-actions">
+7 -4
View File
@@ -511,8 +511,12 @@ export function renderKnownWordsDecksInput(
void loadAnkiDeckFieldNames(deckName, draftUrl);
}
const row = createElement('div', 'deck-field-row');
const header = createElement('div', 'deck-field-row-header');
const usedDeckNames = new Set(Object.keys(currentDecks));
const deckSelect = createElement('select', 'config-input') as HTMLSelectElement;
const deckSelect = createElement(
'select',
'config-input deck-field-row-name',
) as HTMLSelectElement;
for (const candidateDeck of uniqueSorted([...deckNames, deckName])) {
if (candidateDeck !== deckName && usedDeckNames.has(candidateDeck)) continue;
addOption(deckSelect, candidateDeck);
@@ -534,7 +538,6 @@ export function renderKnownWordsDecksInput(
const availableFields = deckName ? (state.deckFieldNames.get(deckName) ?? []) : [];
const fieldNames = uniqueSorted([...availableFields, ...selectedFields]);
const fieldsWrap = createElement('div', 'deck-field-fields');
const fieldActions = createElement('div', 'deck-field-actions');
const checkboxList = createElement('div', 'field-checkbox-list');
@@ -569,7 +572,6 @@ export function renderKnownWordsDecksInput(
});
fieldActions.append(selectAllButton, clearButton);
fieldsWrap.append(fieldActions, checkboxList);
if (state.deckFieldNamesLoading.has(deckName)) {
const hint = createElement('div', 'control-hint');
@@ -609,7 +611,8 @@ export function renderKnownWordsDecksInput(
requestRender();
});
row.append(deckSelect, fieldsWrap, removeButton);
header.append(deckSelect, removeButton);
row.append(header, fieldActions, checkboxList);
const error = state.deckFieldNamesErrors.get(deckName);
if (error) {
const hint = createElement('div', 'control-hint error');
+3 -2
View File
@@ -1,4 +1,5 @@
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
import { toConfigDraftValue, toSettingsDisplayValue } from './settings-model';
import { parseOptionalNumberInputValue } from './input-values';
import {
configureAnkiControls,
@@ -143,7 +144,7 @@ export function renderControl(
field: ConfigSettingsField,
context: SettingsControlContext,
): HTMLElement {
const value = context.valueForField(field);
const value = toSettingsDisplayValue(field.configPath, context.valueForField(field));
if (field.control === 'keyboard-shortcut') {
return renderKeyboardInput(context, field, 'accelerator');
@@ -199,7 +200,7 @@ export function renderControl(
if (next.ok) {
input.classList.remove('invalid');
context.setFieldError(field.configPath, null);
context.updateDraft(field.configPath, next.value);
context.updateDraft(field.configPath, toConfigDraftValue(field.configPath, next.value));
} else {
input.classList.add('invalid');
context.setFieldError(field.configPath, 'Invalid number');
+10 -1
View File
@@ -6,6 +6,8 @@ import {
setDraftValue,
resetDraftPath,
getDirtyOperations,
toConfigDraftValue,
toSettingsDisplayValue,
} from './settings-model';
import type { ConfigSettingsField } from '../types/settings';
@@ -16,7 +18,7 @@ const fields: ConfigSettingsField[] = [
description: 'Pause while hovering subtitles.',
configPath: 'subtitleStyle.autoPauseVideoOnHover',
category: 'behavior',
section: 'Playback Pause Behavior',
section: 'Playback Behavior',
control: 'boolean',
defaultValue: true,
restartBehavior: 'hot-reload',
@@ -147,3 +149,10 @@ test('settings draft emits reset operations for css-editor-owned legacy style pa
},
]);
});
test('discord presence update interval displays seconds while saving milliseconds', () => {
const path = 'discordPresence.updateIntervalMs';
assert.equal(toSettingsDisplayValue(path, 3000), 3);
assert.equal(toConfigDraftValue(path, 2.5), 2500);
});
+20
View File
@@ -71,6 +71,26 @@ export function createSettingsDraft(
};
}
export function toSettingsDisplayValue(
path: string,
value: ConfigSettingsSnapshotValue,
): ConfigSettingsSnapshotValue {
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
return value / 1000;
}
return value;
}
export function toConfigDraftValue(
path: string,
value: ConfigSettingsSnapshotValue,
): ConfigSettingsSnapshotValue {
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
return Math.round(value * 1000);
}
return value;
}
export function setDraftValue(
draft: SettingsDraft,
path: string,
+16 -9
View File
@@ -615,17 +615,25 @@ code {
}
.deck-field-row {
display: grid;
grid-template-columns: minmax(140px, 0.75fr) minmax(220px, 1.25fr) auto;
gap: 8px;
align-items: start;
}
.deck-field-fields {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
gap: 8px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(24, 25, 38, 0.4);
}
.deck-field-row-header {
display: flex;
align-items: center;
gap: 8px;
}
.deck-field-row-name {
min-width: 0;
flex: 1 1 auto;
}
.deck-field-actions {
@@ -823,7 +831,6 @@ code {
.settings-toolbar,
.field-row,
.field-control,
.deck-field-row,
.keybinding-row {
display: flex;
flex-direction: column;
-172
View File
@@ -1,172 +0,0 @@
'use strict';
var __importDefault =
(this && this.__importDefault) ||
function (mod) {
return mod && mod.__esModule ? mod : { default: mod };
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.createUpdateService = createUpdateService;
exports.createFileUpdateStateStore = createFileUpdateStateStore;
const node_fs_1 = __importDefault(require('node:fs'));
const node_path_1 = __importDefault(require('node:path'));
const release_assets_1 = require('./release-assets');
function getBestLatestVersion(currentVersion, appUpdate, release) {
const releaseVersion = (0, release_assets_1.parseReleaseVersion)(release);
const candidates = [appUpdate.version, releaseVersion].filter(
(value) => typeof value === 'string' && value.length > 0,
);
const latest = candidates.reduce(
(best, candidate) =>
(0, release_assets_1.compareSemverLike)(candidate, best) > 0 ? candidate : best,
currentVersion,
);
return {
available:
appUpdate.available || (0, release_assets_1.compareSemverLike)(latest, currentVersion) > 0,
version: latest,
};
}
function shouldSkipAutomaticCheck(config, state, now) {
if (!config.enabled) return true;
if (!state.lastAutomaticCheckAt) return false;
const intervalMs = Math.max(1, config.checkIntervalHours) * 60 * 60 * 1000;
return now - state.lastAutomaticCheckAt < intervalMs;
}
function summarizeError(error) {
const raw = error instanceof Error ? error.message : String(error);
const firstLine = raw
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0);
return firstLine ?? 'unknown error';
}
function createUpdateService(deps) {
const inFlightBySource = new Map();
async function runCheck(request) {
const now = deps.now();
const config = deps.getConfig();
const channel = config.channel;
const state = await deps.readState();
const isAutomatic = request.source === 'automatic';
if (isAutomatic && !request.force && shouldSkipAutomaticCheck(config, state, now)) {
return { status: 'skipped' };
}
try {
const appUpdate = await deps.checkAppUpdate(channel).catch((error) => {
if (isAutomatic) {
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
}
return {
available: false,
version: deps.getCurrentVersion(),
};
});
const shouldFetchReleaseMetadata =
deps.shouldFetchReleaseMetadata?.({ request, channel, appUpdate }) ?? true;
const release = shouldFetchReleaseMetadata
? await deps.fetchLatestStableRelease(channel).catch((error) => {
deps.log(`GitHub release update check failed: ${summarizeError(error)}`);
return null;
})
: null;
const currentVersion = deps.getCurrentVersion();
const latest = getBestLatestVersion(currentVersion, appUpdate, release);
if (isAutomatic) {
const nextState = {
...state,
lastAutomaticCheckAt: now,
};
if (latest.available && state.lastNotifiedVersion !== latest.version) {
await deps.notifyUpdateAvailable(latest.version);
nextState.lastNotifiedVersion = latest.version;
}
await deps.writeState(nextState);
}
if (!latest.available) {
if (!isAutomatic) {
await deps.showNoUpdateDialog(currentVersion);
}
return { status: 'up-to-date', version: currentVersion };
}
if (isAutomatic) {
return { status: 'update-available', version: latest.version };
}
const choice = await deps.showUpdateAvailableDialog(latest.version);
if (choice === 'close') {
return { status: 'update-available', version: latest.version };
}
const canInstallAppUpdate = appUpdate.available && appUpdate.canUpdate !== false;
let appUpdateApplied = false;
if (canInstallAppUpdate) {
await deps.downloadAppUpdate();
appUpdateApplied = true;
}
const launcherResult = await deps.updateLauncher(request.launcherPath, channel, release);
if (launcherResult.status === 'protected' && launcherResult.command) {
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
}
if (!appUpdateApplied) {
await deps.showManualUpdateRequiredDialog(latest.version);
return { status: 'update-available', version: latest.version };
}
const restartChoice = await deps.showRestartDialog();
if (restartChoice === 'restart') {
await deps.quitAndInstall();
}
return { status: 'updated', version: latest.version };
} catch (error) {
const message = summarizeError(error);
if (isAutomatic) {
deps.log(`Automatic update check failed: ${message}`);
} else {
await deps.showUpdateFailedDialog(message);
}
return { status: 'failed', error: message };
}
}
return {
checkForUpdates(request) {
const inFlight = inFlightBySource.get(request.source);
if (inFlight) return inFlight;
const nextInFlight = runCheck(request).finally(() => {
inFlightBySource.delete(request.source);
});
inFlightBySource.set(request.source, nextInFlight);
return nextInFlight;
},
startAutomaticChecks(options = {}) {
const setTimeoutFn = deps.setTimeout ?? setTimeout;
const setIntervalFn = deps.setInterval ?? setInterval;
const startupDelayMs = options.startupDelayMs ?? 15_000;
const pollIntervalMs = options.pollIntervalMs ?? 60 * 60 * 1000;
setTimeoutFn(() => {
void this.checkForUpdates({ source: 'automatic' });
}, startupDelayMs);
setIntervalFn(() => {
void this.checkForUpdates({ source: 'automatic' });
}, pollIntervalMs);
},
};
}
function createFileUpdateStateStore(statePath) {
return {
async readState() {
try {
return JSON.parse(await node_fs_1.default.promises.readFile(statePath, 'utf8'));
} catch {
return {};
}
},
async writeState(state) {
await node_fs_1.default.promises.mkdir(node_path_1.default.dirname(statePath), {
recursive: true,
});
await node_fs_1.default.promises.writeFile(
statePath,
`${JSON.stringify(state, null, 2)}\n`,
'utf8',
);
},
};
}
//# sourceMappingURL=update-service.js.map