mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
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:
@@ -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
@@ -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,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,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal.
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -53,7 +53,7 @@ function createContext(): LauncherCommandContext {
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
configSettings: false,
|
||||
settings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -133,7 +133,7 @@ export interface Args {
|
||||
doctorRefreshKnownWords: boolean;
|
||||
version: boolean;
|
||||
update?: boolean;
|
||||
configSettings: boolean;
|
||||
settings: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
|
||||
+18
-20
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
click: handlers.openRuntimeOptions,
|
||||
},
|
||||
{
|
||||
label: 'Open Configuration',
|
||||
label: 'Open Settings',
|
||||
click: handlers.openConfigSettings,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user