mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 12:55:20 -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
|
```bash
|
||||||
subminer video.mkv # play video with overlay
|
subminer video.mkv # play video with overlay
|
||||||
subminer stats # open immersion dashboard
|
subminer stats # open immersion dashboard
|
||||||
subminer config # open configuration window
|
subminer settings # open settings window
|
||||||
subminer --config # open configuration window via flag
|
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.
|
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
|
type: changed
|
||||||
area: config
|
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
|
type: fixed
|
||||||
area: config
|
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
|
type: added
|
||||||
area: config
|
area: config
|
||||||
|
|
||||||
- Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer config`.
|
- Added a dedicated Settings window with launcher entry points via `subminer --settings` and `subminer settings`.
|
||||||
- Fixed the Configuration window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
|
- Fixed the Settings 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.
|
- Kept settings-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.
|
- 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 Configuration window while keeping them supported in config files.
|
- 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
|
type: fixed
|
||||||
area: launcher
|
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
|
type: fixed
|
||||||
area: config
|
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
|
## 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:
|
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.
|
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
|
## 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 -b` | Start or reuse background stats daemon (non-blocking) |
|
||||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
||||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
| `subminer doctor` | Dependency + config + socket diagnostics |
|
||||||
|
| `subminer settings` | Open the SubMiner settings window |
|
||||||
| `subminer config path` | Print active config file path |
|
| `subminer config path` | Print active config file path |
|
||||||
| `subminer config show` | Print active config contents |
|
| `subminer config show` | Print active config contents |
|
||||||
| `subminer mpv status` | Check mpv socket readiness |
|
| `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**
|
**Yomitan lookup popup does not appear when hovering words or triggering lookup**
|
||||||
|
|
||||||
- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension".
|
- 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 `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.
|
- 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 --dev # Enable app/dev mode only
|
||||||
SubMiner.AppImage --start --debug # Alias for --dev
|
SubMiner.AppImage --start --debug # Alias for --dev
|
||||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
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 # 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-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
|
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 jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||||
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
- `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 mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
- `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.
|
- 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.
|
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.
|
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) {
|
if (!appPath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (args.configSettings) {
|
if (args.settings) {
|
||||||
runAppCommandWithInherit(appPath, ['--config']);
|
runAppCommandWithInherit(appPath, ['--settings']);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!args.appPassthrough) {
|
if (!args.appPassthrough) {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function createContext(): LauncherCommandContext {
|
|||||||
doctor: false,
|
doctor: false,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
version: false,
|
version: false,
|
||||||
configSettings: false,
|
settings: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
action: 'show',
|
action: 'show',
|
||||||
logLevel: 'warn',
|
logLevel: 'warn',
|
||||||
},
|
},
|
||||||
|
settingsInvocation: null,
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
dictionaryTriggered: false,
|
dictionaryTriggered: false,
|
||||||
@@ -159,13 +160,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
assert.equal(parsed.logLevel, 'warn');
|
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({});
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
applyInvocationsToArgs(parsed, {
|
applyInvocationsToArgs(parsed, {
|
||||||
jellyfinInvocation: null,
|
jellyfinInvocation: null,
|
||||||
configInvocation: {
|
configInvocation: null,
|
||||||
action: undefined,
|
settingsInvocation: {
|
||||||
|
logLevel: undefined,
|
||||||
},
|
},
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
@@ -190,16 +192,54 @@ test('applyInvocationsToArgs maps bare config invocation to settings window', ()
|
|||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(parsed.configSettings, true);
|
assert.equal(parsed.settings, true);
|
||||||
assert.equal(parsed.configPath, false);
|
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', () => {
|
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||||
const parsed = createDefaultArgs({});
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
applyInvocationsToArgs(parsed, {
|
applyInvocationsToArgs(parsed, {
|
||||||
jellyfinInvocation: null,
|
jellyfinInvocation: null,
|
||||||
configInvocation: null,
|
configInvocation: null,
|
||||||
|
settingsInvocation: null,
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
dictionaryTriggered: false,
|
dictionaryTriggered: false,
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export function createDefaultArgs(
|
|||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
version: false,
|
version: false,
|
||||||
update: false,
|
update: false,
|
||||||
configSettings: false,
|
settings: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
@@ -222,7 +222,7 @@ export function applyRootOptionsToArgs(
|
|||||||
if (options.rofi === true) parsed.useRofi = true;
|
if (options.rofi === true) parsed.useRofi = true;
|
||||||
if (options.update === true) parsed.update = true;
|
if (options.update === true) parsed.update = true;
|
||||||
if (options.version === true) parsed.version = 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.startOverlay === true) parsed.autoStartOverlay = true;
|
||||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
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);
|
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
|
||||||
}
|
}
|
||||||
const action = (invocations.configInvocation.action || '').toLowerCase();
|
const action = (invocations.configInvocation.action || '').toLowerCase();
|
||||||
if (!action) parsed.configSettings = true;
|
if (action === 'path') parsed.configPath = true;
|
||||||
else if (action === 'path') parsed.configPath = true;
|
|
||||||
else if (action === 'show') parsed.configShow = 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) {
|
if (invocations.mpvInvocation) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface CommandActionInvocation {
|
|||||||
export interface CliInvocations {
|
export interface CliInvocations {
|
||||||
jellyfinInvocation: JellyfinInvocation | null;
|
jellyfinInvocation: JellyfinInvocation | null;
|
||||||
configInvocation: CommandActionInvocation | null;
|
configInvocation: CommandActionInvocation | null;
|
||||||
|
settingsInvocation: CommandActionInvocation | null;
|
||||||
mpvInvocation: CommandActionInvocation | null;
|
mpvInvocation: CommandActionInvocation | null;
|
||||||
appInvocation: { appArgs: string[] } | null;
|
appInvocation: { appArgs: string[] } | null;
|
||||||
dictionaryTriggered: boolean;
|
dictionaryTriggered: boolean;
|
||||||
@@ -58,7 +59,7 @@ function applyRootOptions(program: Command): void {
|
|||||||
.option('--start', 'Explicitly start overlay')
|
.option('--start', 'Explicitly start overlay')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.option('-v, --version', 'Show SubMiner version')
|
.option('-v, --version', 'Show SubMiner version')
|
||||||
.option('--config', 'Open configuration window')
|
.option('--settings', 'Open settings window')
|
||||||
.option('-u, --update', 'Check for updates')
|
.option('-u, --update', 'Check for updates')
|
||||||
.option('-R, --rofi', 'Use rofi picker')
|
.option('-R, --rofi', 'Use rofi picker')
|
||||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||||
@@ -88,6 +89,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
|||||||
'jf',
|
'jf',
|
||||||
'doctor',
|
'doctor',
|
||||||
'config',
|
'config',
|
||||||
|
'settings',
|
||||||
'mpv',
|
'mpv',
|
||||||
'dictionary',
|
'dictionary',
|
||||||
'dict',
|
'dict',
|
||||||
@@ -138,6 +140,7 @@ export function parseCliPrograms(
|
|||||||
} {
|
} {
|
||||||
let jellyfinInvocation: JellyfinInvocation | null = null;
|
let jellyfinInvocation: JellyfinInvocation | null = null;
|
||||||
let configInvocation: CommandActionInvocation | null = null;
|
let configInvocation: CommandActionInvocation | null = null;
|
||||||
|
let settingsInvocation: CommandActionInvocation | null = null;
|
||||||
let mpvInvocation: CommandActionInvocation | null = null;
|
let mpvInvocation: CommandActionInvocation | null = null;
|
||||||
let appInvocation: { appArgs: string[] } | null = null;
|
let appInvocation: { appArgs: string[] } | null = null;
|
||||||
let dictionaryTriggered = false;
|
let dictionaryTriggered = false;
|
||||||
@@ -293,7 +296,7 @@ export function parseCliPrograms(
|
|||||||
|
|
||||||
commandProgram
|
commandProgram
|
||||||
.command('config')
|
.command('config')
|
||||||
.description('Config helpers')
|
.description('Config file helpers (path|show)')
|
||||||
.argument('[action]', 'path|show')
|
.argument('[action]', 'path|show')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
.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
|
commandProgram
|
||||||
.command('mpv')
|
.command('mpv')
|
||||||
.description('MPV helpers')
|
.description('MPV helpers')
|
||||||
@@ -356,6 +369,7 @@ export function parseCliPrograms(
|
|||||||
invocations: {
|
invocations: {
|
||||||
jellyfinInvocation,
|
jellyfinInvocation,
|
||||||
configInvocation,
|
configInvocation,
|
||||||
|
settingsInvocation,
|
||||||
mpvInvocation,
|
mpvInvocation,
|
||||||
appInvocation,
|
appInvocation,
|
||||||
dictionaryTriggered,
|
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) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
@@ -249,14 +249,14 @@ test('launcher config option forwards app configuration window command', () => {
|
|||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
SUBMINER_TEST_CAPTURE: capturePath,
|
SUBMINER_TEST_CAPTURE: capturePath,
|
||||||
};
|
};
|
||||||
const result = runLauncher(['--config'], env);
|
const result = runLauncher(['--settings'], env);
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
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) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
@@ -273,14 +273,14 @@ test('launcher config command forwards app configuration window command', () =>
|
|||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
SUBMINER_TEST_CAPTURE: capturePath,
|
SUBMINER_TEST_CAPTURE: capturePath,
|
||||||
};
|
};
|
||||||
const result = runLauncher(['config'], env);
|
const result = runLauncher(['settings'], env);
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
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) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
@@ -301,7 +301,7 @@ test('launcher config command suppresses known Electron macOS menu diagnostics',
|
|||||||
...makeTestEnv(homeDir, xdgConfigHome),
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
};
|
};
|
||||||
const result = runLauncher(['config'], env);
|
const result = runLauncher(['settings'], env);
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
assert.equal(result.status, 0);
|
||||||
assert.equal(result.stderr, 'real stderr line\n');
|
assert.equal(result.stderr, 'real stderr line\n');
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
doctor: false,
|
doctor: false,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
version: false,
|
version: false,
|
||||||
configSettings: false,
|
settings: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ test('parseArgs captures mpv args string', () => {
|
|||||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs maps root config window option', () => {
|
test('parseArgs maps root settings window option', () => {
|
||||||
const parsed = parseArgs(['--config'], 'subminer', {});
|
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', () => {
|
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);
|
assert.equal(parsed.configPath, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs maps bare config command to settings window', () => {
|
test('parseArgs maps settings command to settings window', () => {
|
||||||
const parsed = parseArgs(['config'], 'subminer', {});
|
const parsed = parseArgs(['settings'], 'subminer', {});
|
||||||
|
|
||||||
assert.equal(parsed.configSettings, true);
|
assert.equal(parsed.settings, true);
|
||||||
assert.equal(parsed.configPath, false);
|
assert.equal(parsed.configPath, false);
|
||||||
assert.equal(parsed.configShow, 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', {});
|
const parsed = parseArgs(['config', 'path'], 'subminer', {});
|
||||||
|
|
||||||
assert.equal(parsed.configPath, true);
|
assert.equal(parsed.configPath, true);
|
||||||
assert.equal(parsed.configSettings, false);
|
assert.equal(parsed.settings, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs rejects removed config open and launch actions', () => {
|
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);
|
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', () => {
|
test('parseArgs maps mpv idle action', () => {
|
||||||
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
|
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ export interface Args {
|
|||||||
doctorRefreshKnownWords: boolean;
|
doctorRefreshKnownWords: boolean;
|
||||||
version: boolean;
|
version: boolean;
|
||||||
update?: boolean;
|
update?: boolean;
|
||||||
configSettings: boolean;
|
settings: boolean;
|
||||||
configPath: boolean;
|
configPath: boolean;
|
||||||
configShow: boolean;
|
configShow: boolean;
|
||||||
mpvIdle: boolean;
|
mpvIdle: boolean;
|
||||||
|
|||||||
+18
-20
@@ -7,7 +7,7 @@ import {
|
|||||||
isHeadlessInitialCommand,
|
isHeadlessInitialCommand,
|
||||||
isStandaloneTexthookerCommand,
|
isStandaloneTexthookerCommand,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
shouldRunSettingsOnlyStartup,
|
shouldRunYomitanOnlyStartup,
|
||||||
shouldStartApp,
|
shouldStartApp,
|
||||||
} from './args';
|
} from './args';
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ test('parseArgs captures update command and internal launcher paths', () => {
|
|||||||
assert.equal(hasExplicitCommand(args), true);
|
assert.equal(hasExplicitCommand(args), true);
|
||||||
assert.equal(shouldStartApp(args), true);
|
assert.equal(shouldStartApp(args), true);
|
||||||
assert.equal(commandNeedsOverlayRuntime(args), false);
|
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', () => {
|
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(shouldStartApp(update), true);
|
||||||
assert.equal(isHeadlessInitialCommand(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']);
|
const settings = parseArgs(['--settings']);
|
||||||
assert.equal(settings.settings, true);
|
assert.equal(settings.settings, true);
|
||||||
assert.equal(hasExplicitCommand(settings), true);
|
assert.equal(hasExplicitCommand(settings), true);
|
||||||
assert.equal(shouldStartApp(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']);
|
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
|
||||||
assert.equal(configSettings.configSettings, true);
|
assert.equal(yomitanWithOverlay.yomitan, true);
|
||||||
assert.equal(hasExplicitCommand(configSettings), true);
|
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
|
||||||
assert.equal(shouldStartApp(configSettings), true);
|
assert.equal(shouldRunYomitanOnlyStartup(yomitanWithOverlay), false);
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
|
|
||||||
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
|
|
||||||
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
|
|
||||||
|
|
||||||
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
|
const settingsDoesNotEnableYomitan = parseArgs(['--settings']);
|
||||||
assert.equal(settingsWithOverlay.settings, true);
|
assert.equal(settingsDoesNotEnableYomitan.yomitan, false);
|
||||||
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 help = parseArgs(['--help']);
|
const help = parseArgs(['--help']);
|
||||||
assert.equal(help.help, true);
|
assert.equal(help.help, true);
|
||||||
assert.equal(hasExplicitCommand(help), true);
|
assert.equal(hasExplicitCommand(help), true);
|
||||||
assert.equal(shouldStartApp(help), false);
|
assert.equal(shouldStartApp(help), false);
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(help), false);
|
assert.equal(shouldRunYomitanOnlyStartup(help), false);
|
||||||
|
|
||||||
const appPing = parseArgs(['--app-ping']);
|
const appPing = parseArgs(['--app-ping']);
|
||||||
assert.equal(appPing.appPing, true);
|
assert.equal(appPing.appPing, true);
|
||||||
|
|||||||
+10
-10
@@ -10,8 +10,8 @@ export interface CliArgs {
|
|||||||
toggle: boolean;
|
toggle: boolean;
|
||||||
toggleVisibleOverlay: boolean;
|
toggleVisibleOverlay: boolean;
|
||||||
togglePrimarySubtitleBar: boolean;
|
togglePrimarySubtitleBar: boolean;
|
||||||
|
yomitan: boolean;
|
||||||
settings: boolean;
|
settings: boolean;
|
||||||
configSettings: boolean;
|
|
||||||
setup: boolean;
|
setup: boolean;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
hide: boolean;
|
hide: boolean;
|
||||||
@@ -117,8 +117,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configSettings: false,
|
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -239,8 +239,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (arg === '--toggle') args.toggle = true;
|
else if (arg === '--toggle') args.toggle = true;
|
||||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||||
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
|
else if (arg === '--yomitan') args.yomitan = true;
|
||||||
else if (arg === '--config') args.configSettings = true;
|
else if (arg === '--settings') args.settings = true;
|
||||||
else if (arg === '--setup') args.setup = true;
|
else if (arg === '--setup') args.setup = true;
|
||||||
else if (arg === '--show') args.show = true;
|
else if (arg === '--show') args.show = true;
|
||||||
else if (arg === '--hide') args.hide = true;
|
else if (arg === '--hide') args.hide = true;
|
||||||
@@ -494,8 +494,8 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.toggle ||
|
args.toggle ||
|
||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
args.togglePrimarySubtitleBar ||
|
args.togglePrimarySubtitleBar ||
|
||||||
|
args.yomitan ||
|
||||||
args.settings ||
|
args.settings ||
|
||||||
args.configSettings ||
|
|
||||||
args.setup ||
|
args.setup ||
|
||||||
args.show ||
|
args.show ||
|
||||||
args.hide ||
|
args.hide ||
|
||||||
@@ -569,8 +569,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.toggle &&
|
!args.toggle &&
|
||||||
!args.toggleVisibleOverlay &&
|
!args.toggleVisibleOverlay &&
|
||||||
!args.togglePrimarySubtitleBar &&
|
!args.togglePrimarySubtitleBar &&
|
||||||
|
!args.yomitan &&
|
||||||
!args.settings &&
|
!args.settings &&
|
||||||
!args.configSettings &&
|
|
||||||
!args.setup &&
|
!args.setup &&
|
||||||
!args.show &&
|
!args.show &&
|
||||||
!args.hide &&
|
!args.hide &&
|
||||||
@@ -639,8 +639,8 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.toggle ||
|
args.toggle ||
|
||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
args.togglePrimarySubtitleBar ||
|
args.togglePrimarySubtitleBar ||
|
||||||
|
args.yomitan ||
|
||||||
args.settings ||
|
args.settings ||
|
||||||
args.configSettings ||
|
|
||||||
args.setup ||
|
args.setup ||
|
||||||
args.copySubtitle ||
|
args.copySubtitle ||
|
||||||
args.copySubtitleMultiple ||
|
args.copySubtitleMultiple ||
|
||||||
@@ -687,16 +687,16 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||||
return (
|
return (
|
||||||
args.settings &&
|
args.yomitan &&
|
||||||
!args.background &&
|
!args.background &&
|
||||||
!args.start &&
|
!args.start &&
|
||||||
!args.stop &&
|
!args.stop &&
|
||||||
!args.toggle &&
|
!args.toggle &&
|
||||||
!args.toggleVisibleOverlay &&
|
!args.toggleVisibleOverlay &&
|
||||||
!args.togglePrimarySubtitleBar &&
|
!args.togglePrimarySubtitleBar &&
|
||||||
!args.configSettings &&
|
!args.settings &&
|
||||||
!args.show &&
|
!args.show &&
|
||||||
!args.hide &&
|
!args.hide &&
|
||||||
!args.setup &&
|
!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.match(output, /--open-browser\s+Open texthooker in your default browser/);
|
||||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
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, /--mark-watched\s+Mark current video watched and advance playlist/);
|
||||||
assert.match(output, /--anilist-status/);
|
assert.match(output, /--anilist-status/);
|
||||||
assert.match(output, /--anilist-retry-queue/);
|
assert.match(output, /--anilist-retry-queue/);
|
||||||
|
|||||||
+2
-2
@@ -24,8 +24,8 @@ ${B}Overlay${R}
|
|||||||
--toggle-primary-subtitle-bar Toggle primary subtitle bar
|
--toggle-primary-subtitle-bar Toggle primary subtitle bar
|
||||||
--show-visible-overlay Show subtitle overlay
|
--show-visible-overlay Show subtitle overlay
|
||||||
--hide-visible-overlay Hide subtitle overlay
|
--hide-visible-overlay Hide subtitle overlay
|
||||||
--settings Open Yomitan settings window
|
--yomitan Open Yomitan settings window
|
||||||
--config Open configuration window
|
--settings Open SubMiner settings window
|
||||||
--setup Open first-run setup window
|
--setup Open first-run setup window
|
||||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
--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('subtitlePosition.yPercent').label, 'Subtitle Position');
|
||||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
|
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
|
||||||
assert.equal(field('auto_start_overlay').category, 'behavior');
|
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').category, 'behavior');
|
||||||
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
|
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
|
||||||
assert.equal(field('mpv.launchMode').category, 'behavior');
|
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(
|
assert.ok(
|
||||||
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
||||||
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
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', () => {
|
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').section, 'Annotation Display');
|
||||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
|
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', () => {
|
test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||||
for (const path of [
|
for (const path of [
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
'stats.toggleKey',
|
'stats.toggleKey',
|
||||||
'stats.markWatchedKey',
|
'stats.markWatchedKey',
|
||||||
'logging.level',
|
'logging.level',
|
||||||
|
|||||||
@@ -65,13 +65,17 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
|||||||
'youtubeSubgen.primarySubLanguages',
|
'youtubeSubgen.primarySubLanguages',
|
||||||
'anilist.characterDictionary.refreshTtlHours',
|
'anilist.characterDictionary.refreshTtlHours',
|
||||||
'anilist.characterDictionary.evictionPolicy',
|
'anilist.characterDictionary.evictionPolicy',
|
||||||
|
'anilist.characterDictionary.profileScope',
|
||||||
'jellyfin.accessToken',
|
'jellyfin.accessToken',
|
||||||
'jellyfin.userId',
|
'jellyfin.userId',
|
||||||
'jellyfin.clientName',
|
'jellyfin.clientName',
|
||||||
'jellyfin.clientVersion',
|
'jellyfin.clientVersion',
|
||||||
'jellyfin.defaultLibraryId',
|
'jellyfin.defaultLibraryId',
|
||||||
'jellyfin.deviceId',
|
'jellyfin.deviceId',
|
||||||
|
'jellyfin.directPlayContainers',
|
||||||
|
'jellyfin.remoteControlDeviceName',
|
||||||
'controller.buttonIndices',
|
'controller.buttonIndices',
|
||||||
|
'shortcuts.multiCopyTimeoutMs',
|
||||||
'subtitleSidebar.toggleKey',
|
'subtitleSidebar.toggleKey',
|
||||||
'jellyfin.recentServers',
|
'jellyfin.recentServers',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -123,12 +127,11 @@ const SECTION_ORDER = new Map<string, number>(
|
|||||||
'Primary Subtitle Appearance',
|
'Primary Subtitle Appearance',
|
||||||
'Secondary Subtitle Appearance',
|
'Secondary Subtitle Appearance',
|
||||||
'Subtitle Sidebar Appearance',
|
'Subtitle Sidebar Appearance',
|
||||||
'Playback Pause Behavior',
|
'Playback Behavior',
|
||||||
'Subtitle Behavior',
|
'Subtitle Behavior',
|
||||||
'Subtitle Sidebar Behavior',
|
'Subtitle Sidebar Behavior',
|
||||||
'Visible Overlay Auto-Start',
|
|
||||||
'YouTube Playback Settings',
|
'YouTube Playback Settings',
|
||||||
'MPV Launcher',
|
'mpv Playback',
|
||||||
'Note Fields',
|
'Note Fields',
|
||||||
'Media Capture',
|
'Media Capture',
|
||||||
'Kiku/Lapis Features',
|
'Kiku/Lapis Features',
|
||||||
@@ -140,7 +143,19 @@ const SECTION_ORDER = new Map<string, number>(
|
|||||||
'MPV Keybindings',
|
'MPV Keybindings',
|
||||||
'Overlay Shortcuts',
|
'Overlay Shortcuts',
|
||||||
'Controller',
|
'Controller',
|
||||||
|
'Annotation WebSocket',
|
||||||
|
'WebSocket server',
|
||||||
|
'AniList',
|
||||||
'Character Dictionary',
|
'Character Dictionary',
|
||||||
|
'Discord Rich Presence',
|
||||||
|
'Jellyfin',
|
||||||
|
'Texthooker',
|
||||||
|
'Yomitan',
|
||||||
|
'Stats dashboard',
|
||||||
|
'Startup warmups',
|
||||||
|
'Logging',
|
||||||
|
'Updates',
|
||||||
|
'Immersion tracking',
|
||||||
].map((section, index) => [section, index]),
|
].map((section, index) => [section, index]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -169,9 +184,9 @@ const PATH_ORDER = new Map<string, number>(
|
|||||||
'mpv.backend',
|
'mpv.backend',
|
||||||
'mpv.subminerBinaryPath',
|
'mpv.subminerBinaryPath',
|
||||||
'mpv.aniskipEnabled',
|
'mpv.aniskipEnabled',
|
||||||
'mpv.aniskipButtonKey',
|
|
||||||
'mpv.launchMode',
|
'mpv.launchMode',
|
||||||
'mpv.executablePath',
|
'mpv.executablePath',
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
].map((path, index) => [path, index]),
|
].map((path, index) => [path, index]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -186,7 +201,6 @@ const SUBSECTION_ORDER = new Map<string, number>(
|
|||||||
'Toggle & Visibility',
|
'Toggle & Visibility',
|
||||||
'Open Panels',
|
'Open Panels',
|
||||||
'Playback',
|
'Playback',
|
||||||
'Timing',
|
|
||||||
'Default Fold State',
|
'Default Fold State',
|
||||||
].map((subsection, index) => [subsection, index]),
|
].map((subsection, index) => [subsection, index]),
|
||||||
);
|
);
|
||||||
@@ -215,6 +229,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||||
|
'discordPresence.updateIntervalMs': 'Update Interval Seconds',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
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.',
|
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
|
||||||
'subtitleSidebar.css':
|
'subtitleSidebar.css':
|
||||||
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
|
'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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -295,7 +312,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||||
path === 'subtitleSidebar.pauseVideoOnHover'
|
path === 'subtitleSidebar.pauseVideoOnHover'
|
||||||
) {
|
) {
|
||||||
return { category: 'behavior', section: 'Playback Pause Behavior' };
|
return { category: 'behavior', section: 'Playback Behavior' };
|
||||||
}
|
}
|
||||||
if (path === 'subtitleStyle.preserveLineBreaks') {
|
if (path === 'subtitleStyle.preserveLineBreaks') {
|
||||||
return { category: 'behavior', section: 'Subtitle Behavior' };
|
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||||
@@ -373,8 +390,15 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('ankiConnect.')) {
|
if (path.startsWith('ankiConnect.')) {
|
||||||
return { category: 'mining-anki', section: 'AnkiConnect' };
|
return { category: 'mining-anki', section: 'AnkiConnect' };
|
||||||
}
|
}
|
||||||
if (path === 'auto_start_overlay') {
|
if (
|
||||||
return { category: 'behavior', section: topSection(path) };
|
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.')) {
|
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
|
||||||
return { category: 'behavior', section: topSection(path) };
|
return { category: 'behavior', section: topSection(path) };
|
||||||
@@ -437,7 +461,7 @@ function topSection(path: string): string {
|
|||||||
jimaku: 'Jimaku',
|
jimaku: 'Jimaku',
|
||||||
jellyfin: 'Jellyfin',
|
jellyfin: 'Jellyfin',
|
||||||
logging: 'Logging',
|
logging: 'Logging',
|
||||||
mpv: 'MPV Launcher',
|
mpv: 'mpv Playback',
|
||||||
stats: 'Stats dashboard',
|
stats: 'Stats dashboard',
|
||||||
startupWarmups: 'Startup warmups',
|
startupWarmups: 'Startup warmups',
|
||||||
subsync: 'Subtitle Sync',
|
subsync: 'Subtitle Sync',
|
||||||
@@ -447,7 +471,7 @@ function topSection(path: string): string {
|
|||||||
yomitan: 'Yomitan',
|
yomitan: 'Yomitan',
|
||||||
youtube: 'YouTube Playback Settings',
|
youtube: 'YouTube Playback Settings',
|
||||||
youtubeSubgen: 'YouTube subtitle generation',
|
youtubeSubgen: 'YouTube subtitle generation',
|
||||||
auto_start_overlay: 'Visible Overlay Auto-Start',
|
auto_start_overlay: 'Playback Behavior',
|
||||||
};
|
};
|
||||||
return labels[top] ?? humanizePath(top);
|
return labels[top] ?? humanizePath(top);
|
||||||
}
|
}
|
||||||
@@ -515,9 +539,11 @@ function subsectionForPath(path: string): string | undefined {
|
|||||||
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
||||||
return 'Toggle & Visibility';
|
return 'Toggle & Visibility';
|
||||||
}
|
}
|
||||||
|
if (path === 'mpv.aniskipButtonKey') {
|
||||||
|
return 'Playback';
|
||||||
|
}
|
||||||
if (path.startsWith('shortcuts.')) {
|
if (path.startsWith('shortcuts.')) {
|
||||||
const leaf = path.split('.').at(-1) ?? '';
|
const leaf = path.split('.').at(-1) ?? '';
|
||||||
if (leaf === 'multiCopyTimeoutMs') return 'Timing';
|
|
||||||
if (
|
if (
|
||||||
leaf === 'copySubtitle' ||
|
leaf === 'copySubtitle' ||
|
||||||
leaf === 'copySubtitleMultiple' ||
|
leaf === 'copySubtitleMultiple' ||
|
||||||
@@ -632,6 +658,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'ankiConnect.fields.miscInfo' ||
|
path === 'ankiConnect.fields.miscInfo' ||
|
||||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||||
|
path === 'mpv.aniskipButtonKey' ||
|
||||||
path === 'stats.toggleKey' ||
|
path === 'stats.toggleKey' ||
|
||||||
path === 'stats.markWatchedKey' ||
|
path === 'stats.markWatchedKey' ||
|
||||||
path === 'logging.level' ||
|
path === 'logging.level' ||
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configSettings: false,
|
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -223,3 +223,22 @@ test('startAppLifecycle queues second-instance commands until app ready runtime
|
|||||||
runSecondInstance(['SubMiner', '--start']);
|
runSecondInstance(['SubMiner', '--start']);
|
||||||
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance: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(() => {
|
deps.onWindowAllClosed(() => {
|
||||||
if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) {
|
if (
|
||||||
|
deps.shouldQuitOnWindowAllClosed() &&
|
||||||
|
(!deps.isDarwinPlatform() || initialArgs.settings)
|
||||||
|
) {
|
||||||
deps.quitApp();
|
deps.quitApp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
stop: false,
|
stop: false,
|
||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configSettings: false,
|
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -586,8 +586,8 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
|||||||
args: Partial<CliArgs>;
|
args: Partial<CliArgs>;
|
||||||
expected: string;
|
expected: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
||||||
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
|
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
|
||||||
{
|
{
|
||||||
args: { showVisibleOverlay: true },
|
args: { showVisibleOverlay: true },
|
||||||
expected: 'setVisibleOverlayVisible:true',
|
expected: 'setVisibleOverlayVisible:true',
|
||||||
|
|||||||
@@ -386,9 +386,9 @@ export function handleCliCommand(
|
|||||||
} else if (args.setup) {
|
} else if (args.setup) {
|
||||||
deps.openFirstRunSetup(true);
|
deps.openFirstRunSetup(true);
|
||||||
deps.logDebug('Opened first-run setup flow.');
|
deps.logDebug('Opened first-run setup flow.');
|
||||||
} else if (args.settings) {
|
} else if (args.yomitan) {
|
||||||
deps.openYomitanSettingsDelayed(1000);
|
deps.openYomitanSettingsDelayed(1000);
|
||||||
} else if (args.configSettings) {
|
} else if (args.settings) {
|
||||||
deps.openConfigSettingsWindow();
|
deps.openConfigSettingsWindow();
|
||||||
} else if (args.show || args.showVisibleOverlay) {
|
} else if (args.show || args.showVisibleOverlay) {
|
||||||
deps.setVisibleOverlayVisible(true);
|
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', () => {
|
test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
|
||||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||||
|
next.mpv.aniskipButtonKey = 'F8';
|
||||||
next.stats.toggleKey = 'F8';
|
next.stats.toggleKey = 'F8';
|
||||||
next.stats.markWatchedKey = 'F9';
|
next.stats.markWatchedKey = 'F9';
|
||||||
next.logging.level = 'debug';
|
next.logging.level = 'debug';
|
||||||
@@ -52,6 +53,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
|||||||
new Set(diff.hotReloadFields),
|
new Set(diff.hotReloadFields),
|
||||||
new Set([
|
new Set([
|
||||||
'stats.toggleKey',
|
'stats.toggleKey',
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
'stats.markWatchedKey',
|
'stats.markWatchedKey',
|
||||||
'logging.level',
|
'logging.level',
|
||||||
'youtube.primarySubLanguages',
|
'youtube.primarySubLanguages',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitle
|
|||||||
|
|
||||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||||
'secondarySub.defaultMode',
|
'secondarySub.defaultMode',
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
'ankiConnect.ai.enabled',
|
'ankiConnect.ai.enabled',
|
||||||
'stats.toggleKey',
|
'stats.toggleKey',
|
||||||
'stats.markWatchedKey',
|
'stats.markWatchedKey',
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configSettings: false,
|
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
|
|||||||
+39
-25
@@ -21,7 +21,6 @@ import {
|
|||||||
clipboard,
|
clipboard,
|
||||||
globalShortcut,
|
globalShortcut,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
net,
|
|
||||||
shell,
|
shell,
|
||||||
protocol,
|
protocol,
|
||||||
Extension,
|
Extension,
|
||||||
@@ -91,7 +90,7 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
import * as fs from 'fs';
|
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 os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { MecabTokenizer } from './mecab-tokenizer';
|
import { MecabTokenizer } from './mecab-tokenizer';
|
||||||
@@ -505,11 +504,7 @@ import {
|
|||||||
createElectronAppUpdater,
|
createElectronAppUpdater,
|
||||||
isNativeUpdaterSupported,
|
isNativeUpdaterSupported,
|
||||||
} from './main/runtime/update/app-updater';
|
} from './main/runtime/update/app-updater';
|
||||||
import {
|
import { createCurlFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter';
|
||||||
createCurlFetch,
|
|
||||||
createElectronNetFetch,
|
|
||||||
createGlobalFetch,
|
|
||||||
} from './main/runtime/update/fetch-adapter';
|
|
||||||
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
||||||
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
|
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
|
||||||
import {
|
import {
|
||||||
@@ -618,6 +613,7 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
|
|||||||
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
|
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
|
||||||
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
|
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
|
||||||
const TRAY_TOOLTIP = 'SubMiner';
|
const TRAY_TOOLTIP = 'SubMiner';
|
||||||
|
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
|
||||||
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
|
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
|
||||||
|
|
||||||
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
|
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
|
||||||
@@ -4894,28 +4890,19 @@ flushPendingMpvLogWrites = () => {
|
|||||||
|
|
||||||
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
|
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
|
||||||
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
||||||
const electronNetFetch = createElectronNetFetch({
|
|
||||||
fetch: (url, init) => net.fetch(url, init as RequestInit),
|
|
||||||
});
|
|
||||||
const globalFetchForUpdater = createGlobalFetch();
|
const globalFetchForUpdater = createGlobalFetch();
|
||||||
const curlFetch = createCurlFetch();
|
const curlFetch = createCurlFetch();
|
||||||
|
|
||||||
function createNativeUpdaterHttpExecutor() {
|
function createNativeUpdaterHttpExecutor() {
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
return createCurlHttpExecutor();
|
|
||||||
}
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return createFetchHttpExecutor();
|
return createFetchHttpExecutor();
|
||||||
}
|
}
|
||||||
return undefined;
|
return createCurlHttpExecutor();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFetchForUpdater() {
|
function getFetchForUpdater() {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') return globalFetchForUpdater;
|
||||||
return globalFetchForUpdater;
|
return curlFetch;
|
||||||
}
|
|
||||||
if (process.platform === 'linux') return curlFetch;
|
|
||||||
return electronNetFetch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateLauncherFromSelectedRelease(
|
async function updateLauncherFromSelectedRelease(
|
||||||
@@ -4962,11 +4949,8 @@ function getUpdateService() {
|
|||||||
isPackaged: app.isPackaged,
|
isPackaged: app.isPackaged,
|
||||||
log: (message) => logger.info(message),
|
log: (message) => logger.info(message),
|
||||||
getChannel: () => getResolvedConfig().updates.channel,
|
getChannel: () => getResolvedConfig().updates.channel,
|
||||||
configureHttpExecutor:
|
configureHttpExecutor: createNativeUpdaterHttpExecutor,
|
||||||
process.platform === 'darwin' || process.platform === 'win32'
|
disableDifferentialDownload: true,
|
||||||
? createNativeUpdaterHttpExecutor
|
|
||||||
: undefined,
|
|
||||||
disableDifferentialDownload: process.platform === 'darwin' || process.platform === 'win32',
|
|
||||||
isNativeUpdaterSupported: () =>
|
isNativeUpdaterSupported: () =>
|
||||||
isNativeUpdaterSupported({
|
isNativeUpdaterSupported({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
@@ -4978,7 +4962,37 @@ function getUpdateService() {
|
|||||||
});
|
});
|
||||||
const updateDialogPresenter = createUpdateDialogPresenter({
|
const updateDialogPresenter = createUpdateDialogPresenter({
|
||||||
platform: process.platform,
|
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),
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
});
|
});
|
||||||
updateService = createUpdateService({
|
updateService = createUpdateService({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const fields: ConfigSettingsField[] = [
|
|||||||
description: 'Launch mode setting.',
|
description: 'Launch mode setting.',
|
||||||
configPath: 'mpv.launchMode',
|
configPath: 'mpv.launchMode',
|
||||||
category: 'behavior',
|
category: 'behavior',
|
||||||
section: 'MPV Launcher',
|
section: 'mpv Playback',
|
||||||
control: 'select',
|
control: 'select',
|
||||||
defaultValue: 'windowed',
|
defaultValue: 'windowed',
|
||||||
restartBehavior: 'restart',
|
restartBehavior: 'restart',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
|
|||||||
const window = deps.createSettingsWindow();
|
const window = deps.createSettingsWindow();
|
||||||
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
|
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : String(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);
|
deps.setSettingsWindow(null);
|
||||||
window.destroy?.();
|
window.destroy?.();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configSettings: false,
|
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -122,12 +122,12 @@ function createCommandLineLauncherSnapshot(
|
|||||||
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
||||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
|
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
|
||||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: 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(
|
assert.equal(
|
||||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
|
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
|
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ yomitan: true })), false);
|
||||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
|
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
|||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
args.togglePrimarySubtitleBar ||
|
args.togglePrimarySubtitleBar ||
|
||||||
args.launchMpv ||
|
args.launchMpv ||
|
||||||
|
args.yomitan ||
|
||||||
args.settings ||
|
args.settings ||
|
||||||
args.configSettings ||
|
|
||||||
args.show ||
|
args.show ||
|
||||||
args.hide ||
|
args.hide ||
|
||||||
args.showVisibleOverlay ||
|
args.showVisibleOverlay ||
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind
|
|||||||
assert.deepEqual(options, {
|
assert.deepEqual(options, {
|
||||||
width: 1040,
|
width: 1040,
|
||||||
height: 760,
|
height: 760,
|
||||||
title: 'SubMiner Configuration',
|
title: 'SubMiner Settings',
|
||||||
show: true,
|
show: true,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
|
|||||||
return createSetupWindowHandler(deps, {
|
return createSetupWindowHandler(deps, {
|
||||||
width: 1040,
|
width: 1040,
|
||||||
height: 760,
|
height: 760,
|
||||||
title: 'SubMiner Configuration',
|
title: 'SubMiner Settings',
|
||||||
resizable: true,
|
resizable: true,
|
||||||
preloadPath: deps.preloadPath,
|
preloadPath: deps.preloadPath,
|
||||||
backgroundColor: '#24273a',
|
backgroundColor: '#24273a',
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
shouldStartAutomaticUpdateChecks,
|
shouldStartAutomaticUpdateChecks,
|
||||||
} from './startup-mode-flags';
|
} from './startup-mode-flags';
|
||||||
|
|
||||||
test('config settings startup uses minimal startup and skips background integrations', () => {
|
test('settings window startup uses minimal startup and skips background integrations', () => {
|
||||||
const args = parseArgs(['--config']);
|
const args = parseArgs(['--settings']);
|
||||||
const flags = getStartupModeFlags(args);
|
const flags = getStartupModeFlags(args);
|
||||||
|
|
||||||
assert.equal(flags.shouldUseMinimalStartup, true);
|
assert.equal(flags.shouldUseMinimalStartup, true);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { CliArgs } from '../../cli/args';
|
|||||||
import {
|
import {
|
||||||
isHeadlessInitialCommand,
|
isHeadlessInitialCommand,
|
||||||
isStandaloneTexthookerCommand,
|
isStandaloneTexthookerCommand,
|
||||||
shouldRunSettingsOnlyStartup,
|
shouldRunYomitanOnlyStartup,
|
||||||
} from '../../cli/args';
|
} from '../../cli/args';
|
||||||
|
|
||||||
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||||
@@ -12,15 +12,15 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
|||||||
return {
|
return {
|
||||||
shouldUseMinimalStartup: Boolean(
|
shouldUseMinimalStartup: Boolean(
|
||||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||||
initialArgs?.configSettings ||
|
initialArgs?.settings ||
|
||||||
initialArgs?.update ||
|
initialArgs?.update ||
|
||||||
(initialArgs?.stats &&
|
(initialArgs?.stats &&
|
||||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||||
),
|
),
|
||||||
shouldSkipHeavyStartup: Boolean(
|
shouldSkipHeavyStartup: Boolean(
|
||||||
initialArgs &&
|
initialArgs &&
|
||||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
(shouldRunYomitanOnlyStartup(initialArgs) ||
|
||||||
initialArgs.configSettings ||
|
initialArgs.settings ||
|
||||||
initialArgs.stats ||
|
initialArgs.stats ||
|
||||||
initialArgs.dictionary ||
|
initialArgs.dictionary ||
|
||||||
initialArgs.update ||
|
initialArgs.update ||
|
||||||
@@ -32,9 +32,9 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
|||||||
export function shouldRefreshAnilistOnConfigReload(
|
export function shouldRefreshAnilistOnConfigReload(
|
||||||
initialArgs: CliArgs | null | undefined,
|
initialArgs: CliArgs | null | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
|
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldStartAutomaticUpdateChecks(initialArgs: CliArgs | null | undefined): boolean {
|
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,
|
click: handlers.openRuntimeOptions,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Open Configuration',
|
label: 'Open Settings',
|
||||||
click: handlers.openConfigSettings,
|
click: handlers.openConfigSettings,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ test('mac native updater supports Developer ID signed packaged app bundles', asy
|
|||||||
assert.deepEqual(logged, []);
|
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 logged: string[] = [];
|
||||||
const supported = await isNativeUpdaterSupported({
|
const supported = await isNativeUpdaterSupported({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
@@ -270,10 +270,8 @@ test('linux native updater is unsupported even for writable direct AppImage inst
|
|||||||
log: (message) => logged.push(message),
|
log: (message) => logged.push(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(supported, false);
|
assert.equal(supported, true);
|
||||||
assert.deepEqual(logged, [
|
assert.deepEqual(logged, []);
|
||||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('linux native updater is unsupported when APPIMAGE is missing', async () => {
|
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.equal(supported, false);
|
||||||
assert.deepEqual(logged, [
|
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).',
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
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.',
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -324,7 +304,7 @@ test('linux native updater is unsupported for package-managed AppImage installs'
|
|||||||
|
|
||||||
assert.equal(supported, false);
|
assert.equal(supported, false);
|
||||||
assert.deepEqual(logged, [
|
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.');
|
options.log?.('Skipping native updater because this build is not packaged.');
|
||||||
return false;
|
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') {
|
if (options.platform === 'win32') {
|
||||||
return true;
|
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') {
|
if (options.platform !== 'darwin') {
|
||||||
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
|
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type ShowMessageBox,
|
type ShowMessageBox,
|
||||||
} from './update-dialogs';
|
} 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 calls: string[] = [];
|
||||||
const showMessageBox: ShowMessageBox = async (options) => {
|
const showMessageBox: ShowMessageBox = async (options) => {
|
||||||
calls.push(`dialog:${options.message}`);
|
calls.push(`dialog:${options.message}`);
|
||||||
@@ -14,16 +14,44 @@ test('update dialog presenter focuses app before showing macOS dialogs', async (
|
|||||||
};
|
};
|
||||||
const presenter = createUpdateDialogPresenter({
|
const presenter = createUpdateDialogPresenter({
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
focusApp: () => calls.push('focus'),
|
focusApp: () => {
|
||||||
|
calls.push('focus');
|
||||||
|
},
|
||||||
|
yieldToRunLoop: async () => {
|
||||||
|
calls.push('yield');
|
||||||
|
},
|
||||||
showMessageBox,
|
showMessageBox,
|
||||||
});
|
});
|
||||||
|
|
||||||
await presenter.showNoUpdateDialog('0.14.0');
|
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 calls: string[] = [];
|
||||||
const showMessageBox: ShowMessageBox = async (options) => {
|
const showMessageBox: ShowMessageBox = async (options) => {
|
||||||
calls.push(`dialog:${options.message}`);
|
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({
|
const presenter = createUpdateDialogPresenter({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
focusApp: () => calls.push('focus'),
|
focusApp: () => {
|
||||||
|
calls.push('focus');
|
||||||
|
},
|
||||||
|
yieldToRunLoop: async () => {
|
||||||
|
calls.push('yield');
|
||||||
|
},
|
||||||
showMessageBox,
|
showMessageBox,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export type ShowMessageBox = (options: {
|
|||||||
|
|
||||||
export interface UpdateDialogPresenterDeps {
|
export interface UpdateDialogPresenterDeps {
|
||||||
showMessageBox: ShowMessageBox;
|
showMessageBox: ShowMessageBox;
|
||||||
focusApp?: () => void;
|
focusApp?: () => void | Promise<void>;
|
||||||
|
yieldToRunLoop?: () => Promise<void>;
|
||||||
platform?: NodeJS.Platform;
|
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;
|
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) {
|
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
||||||
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
||||||
maybeFocusAppForDialog(deps);
|
await maybeFocusAppForDialog(deps);
|
||||||
return deps.showMessageBox(options);
|
return deps.showMessageBox(options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,22 +7,22 @@
|
|||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self';"
|
content="default-src 'self'; script-src 'self'; style-src 'self';"
|
||||||
/>
|
/>
|
||||||
<title>SubMiner Configuration</title>
|
<title>SubMiner Settings</title>
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="app" class="settings-shell">
|
<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-block">
|
||||||
<div class="brand-title">SubMiner</div>
|
<div class="brand-title">SubMiner</div>
|
||||||
<div class="brand-subtitle">Configuration</div>
|
<div class="brand-subtitle">Settings</div>
|
||||||
</div>
|
</div>
|
||||||
<nav id="categoryNav" class="category-nav"></nav>
|
<nav id="categoryNav" class="category-nav"></nav>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="settings-main">
|
<section class="settings-main">
|
||||||
<header class="settings-toolbar">
|
<header class="settings-toolbar">
|
||||||
<div class="toolbar-title-block">
|
<div class="toolbar-title-block">
|
||||||
<h1 id="categoryTitle">Configuration</h1>
|
<h1 id="categoryTitle">Settings</h1>
|
||||||
<div id="categoryMeta" class="toolbar-meta"></div>
|
<div id="categoryMeta" class="toolbar-meta"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
|
|||||||
@@ -511,8 +511,12 @@ export function renderKnownWordsDecksInput(
|
|||||||
void loadAnkiDeckFieldNames(deckName, draftUrl);
|
void loadAnkiDeckFieldNames(deckName, draftUrl);
|
||||||
}
|
}
|
||||||
const row = createElement('div', 'deck-field-row');
|
const row = createElement('div', 'deck-field-row');
|
||||||
|
const header = createElement('div', 'deck-field-row-header');
|
||||||
const usedDeckNames = new Set(Object.keys(currentDecks));
|
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])) {
|
for (const candidateDeck of uniqueSorted([...deckNames, deckName])) {
|
||||||
if (candidateDeck !== deckName && usedDeckNames.has(candidateDeck)) continue;
|
if (candidateDeck !== deckName && usedDeckNames.has(candidateDeck)) continue;
|
||||||
addOption(deckSelect, candidateDeck);
|
addOption(deckSelect, candidateDeck);
|
||||||
@@ -534,7 +538,6 @@ export function renderKnownWordsDecksInput(
|
|||||||
|
|
||||||
const availableFields = deckName ? (state.deckFieldNames.get(deckName) ?? []) : [];
|
const availableFields = deckName ? (state.deckFieldNames.get(deckName) ?? []) : [];
|
||||||
const fieldNames = uniqueSorted([...availableFields, ...selectedFields]);
|
const fieldNames = uniqueSorted([...availableFields, ...selectedFields]);
|
||||||
const fieldsWrap = createElement('div', 'deck-field-fields');
|
|
||||||
const fieldActions = createElement('div', 'deck-field-actions');
|
const fieldActions = createElement('div', 'deck-field-actions');
|
||||||
const checkboxList = createElement('div', 'field-checkbox-list');
|
const checkboxList = createElement('div', 'field-checkbox-list');
|
||||||
|
|
||||||
@@ -569,7 +572,6 @@ export function renderKnownWordsDecksInput(
|
|||||||
});
|
});
|
||||||
|
|
||||||
fieldActions.append(selectAllButton, clearButton);
|
fieldActions.append(selectAllButton, clearButton);
|
||||||
fieldsWrap.append(fieldActions, checkboxList);
|
|
||||||
|
|
||||||
if (state.deckFieldNamesLoading.has(deckName)) {
|
if (state.deckFieldNamesLoading.has(deckName)) {
|
||||||
const hint = createElement('div', 'control-hint');
|
const hint = createElement('div', 'control-hint');
|
||||||
@@ -609,7 +611,8 @@ export function renderKnownWordsDecksInput(
|
|||||||
requestRender();
|
requestRender();
|
||||||
});
|
});
|
||||||
|
|
||||||
row.append(deckSelect, fieldsWrap, removeButton);
|
header.append(deckSelect, removeButton);
|
||||||
|
row.append(header, fieldActions, checkboxList);
|
||||||
const error = state.deckFieldNamesErrors.get(deckName);
|
const error = state.deckFieldNamesErrors.get(deckName);
|
||||||
if (error) {
|
if (error) {
|
||||||
const hint = createElement('div', 'control-hint error');
|
const hint = createElement('div', 'control-hint error');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
|
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
|
||||||
|
import { toConfigDraftValue, toSettingsDisplayValue } from './settings-model';
|
||||||
import { parseOptionalNumberInputValue } from './input-values';
|
import { parseOptionalNumberInputValue } from './input-values';
|
||||||
import {
|
import {
|
||||||
configureAnkiControls,
|
configureAnkiControls,
|
||||||
@@ -143,7 +144,7 @@ export function renderControl(
|
|||||||
field: ConfigSettingsField,
|
field: ConfigSettingsField,
|
||||||
context: SettingsControlContext,
|
context: SettingsControlContext,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
const value = context.valueForField(field);
|
const value = toSettingsDisplayValue(field.configPath, context.valueForField(field));
|
||||||
|
|
||||||
if (field.control === 'keyboard-shortcut') {
|
if (field.control === 'keyboard-shortcut') {
|
||||||
return renderKeyboardInput(context, field, 'accelerator');
|
return renderKeyboardInput(context, field, 'accelerator');
|
||||||
@@ -199,7 +200,7 @@ export function renderControl(
|
|||||||
if (next.ok) {
|
if (next.ok) {
|
||||||
input.classList.remove('invalid');
|
input.classList.remove('invalid');
|
||||||
context.setFieldError(field.configPath, null);
|
context.setFieldError(field.configPath, null);
|
||||||
context.updateDraft(field.configPath, next.value);
|
context.updateDraft(field.configPath, toConfigDraftValue(field.configPath, next.value));
|
||||||
} else {
|
} else {
|
||||||
input.classList.add('invalid');
|
input.classList.add('invalid');
|
||||||
context.setFieldError(field.configPath, 'Invalid number');
|
context.setFieldError(field.configPath, 'Invalid number');
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
setDraftValue,
|
setDraftValue,
|
||||||
resetDraftPath,
|
resetDraftPath,
|
||||||
getDirtyOperations,
|
getDirtyOperations,
|
||||||
|
toConfigDraftValue,
|
||||||
|
toSettingsDisplayValue,
|
||||||
} from './settings-model';
|
} from './settings-model';
|
||||||
import type { ConfigSettingsField } from '../types/settings';
|
import type { ConfigSettingsField } from '../types/settings';
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ const fields: ConfigSettingsField[] = [
|
|||||||
description: 'Pause while hovering subtitles.',
|
description: 'Pause while hovering subtitles.',
|
||||||
configPath: 'subtitleStyle.autoPauseVideoOnHover',
|
configPath: 'subtitleStyle.autoPauseVideoOnHover',
|
||||||
category: 'behavior',
|
category: 'behavior',
|
||||||
section: 'Playback Pause Behavior',
|
section: 'Playback Behavior',
|
||||||
control: 'boolean',
|
control: 'boolean',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
restartBehavior: 'hot-reload',
|
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(
|
export function setDraftValue(
|
||||||
draft: SettingsDraft,
|
draft: SettingsDraft,
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
+16
-9
@@ -615,17 +615,25 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.deck-field-row {
|
.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;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
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 {
|
.deck-field-actions {
|
||||||
@@ -823,7 +831,6 @@ code {
|
|||||||
.settings-toolbar,
|
.settings-toolbar,
|
||||||
.field-row,
|
.field-row,
|
||||||
.field-control,
|
.field-control,
|
||||||
.deck-field-row,
|
|
||||||
.keybinding-row {
|
.keybinding-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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