From e84674e3b5513110607f1b0d0198045429af8cba Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 17 May 2026 02:23:44 -0700 Subject: [PATCH] feat(macos): configuration window + curl-backed macOS updater (#71) --- app-updater.js | 193 + backlog/drafts/config-settings-window.md | 15 + bun.lock | 11 + changes/config-settings-window.md | 6 + changes/native-updater-crash.md | 4 +- config.example.jsonc | 118 +- docs-site/configuration.md | 24 +- docs-site/installation.md | 6 +- docs-site/public/config.example.jsonc | 118 +- docs/RELEASING.md | 3 + docs/plans/config-settings-window.md | 70 + launcher/commands/app-command.ts | 9 +- launcher/commands/playback-command.test.ts | 1 + launcher/config/args-normalizer.test.ts | 35 + launcher/config/args-normalizer.ts | 7 +- launcher/config/cli-parser-builder.ts | 7 +- launcher/main.test.ts | 48 + launcher/mpv.test.ts | 1 + launcher/parse-args.test.ts | 33 + launcher/types.ts | 1 + main.js | 4748 +++++++++++++++++ package-lock.json | 4133 ++++++++++++++ package.json | 6 +- release/prerelease-notes.md | 26 +- scripts/prepare-build-assets.mjs | 21 +- src/cli/args.test.ts | 8 + src/cli/args.ts | 7 + src/cli/help.test.ts | 1 + src/cli/help.ts | 1 + src/config/config.test.ts | 5 +- .../definitions/domain-registry.test.ts | 101 + src/config/definitions/options-core.ts | 168 + .../definitions/options-integrations.ts | 233 + src/config/settings/jsonc-edit.test.ts | 94 + src/config/settings/jsonc-edit.ts | 200 + src/config/settings/registry.test.ts | 39 + src/config/settings/registry.ts | 346 ++ src/core/services/app-lifecycle.test.ts | 1 + src/core/services/cli-command.test.ts | 5 + src/core/services/cli-command.ts | 5 + src/core/services/startup-bootstrap.test.ts | 1 + src/core/services/yomitan-extension-copy.ts | 5 +- src/core/services/yomitan-settings.test.ts | 33 +- src/main.ts | 87 +- src/main/cli-runtime.ts | 2 + src/main/dependencies.ts | 2 + src/main/runtime/app-runtime-main-deps.ts | 3 +- .../runtime/cli-command-context-deps.test.ts | 1 + src/main/runtime/cli-command-context-deps.ts | 2 + .../cli-command-context-factory.test.ts | 1 + .../cli-command-context-main-deps.test.ts | 3 + .../runtime/cli-command-context-main-deps.ts | 2 + src/main/runtime/cli-command-context.test.ts | 1 + src/main/runtime/cli-command-context.ts | 2 + .../runtime/command-line-launcher-windows.ts | 8 +- .../composers/cli-startup-composer.test.ts | 1 + src/main/runtime/config-settings-ipc.test.ts | 66 + src/main/runtime/config-settings-ipc.ts | 30 + src/main/runtime/config-settings-runtime.ts | 166 + src/main/runtime/config-settings-save.test.ts | 148 + src/main/runtime/config-settings-save.ts | 98 + .../runtime/config-settings-window.test.ts | 83 + src/main/runtime/config-settings-window.ts | 41 + .../runtime/first-run-setup-service.test.ts | 2 + src/main/runtime/first-run-setup-service.ts | 1 + src/main/runtime/setup-window-factory.test.ts | 29 + src/main/runtime/setup-window-factory.ts | 21 + src/main/runtime/startup-mode-flags.test.ts | 27 + src/main/runtime/startup-mode-flags.ts | 40 + src/main/runtime/tray-main-actions.test.ts | 3 + src/main/runtime/tray-main-actions.ts | 5 + src/main/runtime/tray-main-deps.test.ts | 2 + src/main/runtime/tray-main-deps.ts | 3 + .../runtime/tray-runtime-handlers.test.ts | 1 + src/main/runtime/tray-runtime.test.ts | 14 +- src/main/runtime/tray-runtime.ts | 5 + src/main/runtime/update/app-updater.test.ts | 59 + src/main/runtime/update/app-updater.ts | 43 + .../runtime/update/curl-http-executor.test.ts | 144 + src/main/runtime/update/curl-http-executor.ts | 212 + src/main/runtime/update/fetch-adapter.test.ts | 35 + src/main/runtime/update/fetch-adapter.ts | 9 + .../update/release-metadata-policy.test.ts | 48 + .../runtime/update/release-metadata-policy.ts | 15 + .../runtime/update/update-service.test.ts | 71 +- src/main/runtime/update/update-service.ts | 50 +- src/main/state.ts | 2 + src/preload-settings.test.ts | 10 + src/preload-settings.ts | 25 + src/settings/index.html | 47 + src/settings/input-values.test.ts | 17 + src/settings/input-values.ts | 20 + src/settings/settings-model.test.ts | 62 + src/settings/settings-model.ts | 95 + src/settings/settings.ts | 452 ++ src/settings/style.css | 655 +++ src/shared/ipc/contracts.ts | 4 + src/types.ts | 1 + src/types/settings.ts | 80 + update-service.js | 172 + 100 files changed, 13890 insertions(+), 235 deletions(-) create mode 100644 app-updater.js create mode 100644 backlog/drafts/config-settings-window.md create mode 100644 changes/config-settings-window.md create mode 100644 docs/plans/config-settings-window.md create mode 100644 main.js create mode 100644 package-lock.json create mode 100644 src/config/settings/jsonc-edit.test.ts create mode 100644 src/config/settings/jsonc-edit.ts create mode 100644 src/config/settings/registry.test.ts create mode 100644 src/config/settings/registry.ts create mode 100644 src/main/runtime/config-settings-ipc.test.ts create mode 100644 src/main/runtime/config-settings-ipc.ts create mode 100644 src/main/runtime/config-settings-runtime.ts create mode 100644 src/main/runtime/config-settings-save.test.ts create mode 100644 src/main/runtime/config-settings-save.ts create mode 100644 src/main/runtime/config-settings-window.test.ts create mode 100644 src/main/runtime/config-settings-window.ts create mode 100644 src/main/runtime/startup-mode-flags.test.ts create mode 100644 src/main/runtime/startup-mode-flags.ts create mode 100644 src/main/runtime/update/curl-http-executor.test.ts create mode 100644 src/main/runtime/update/curl-http-executor.ts create mode 100644 src/main/runtime/update/fetch-adapter.test.ts create mode 100644 src/main/runtime/update/fetch-adapter.ts create mode 100644 src/main/runtime/update/release-metadata-policy.test.ts create mode 100644 src/main/runtime/update/release-metadata-policy.ts create mode 100644 src/preload-settings.test.ts create mode 100644 src/preload-settings.ts create mode 100644 src/settings/index.html create mode 100644 src/settings/input-values.test.ts create mode 100644 src/settings/input-values.ts create mode 100644 src/settings/settings-model.test.ts create mode 100644 src/settings/settings-model.ts create mode 100644 src/settings/settings.ts create mode 100644 src/settings/style.css create mode 100644 src/types/settings.ts create mode 100644 update-service.js diff --git a/app-updater.js b/app-updater.js new file mode 100644 index 00000000..4709efa2 --- /dev/null +++ b/app-updater.js @@ -0,0 +1,193 @@ +"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 diff --git a/backlog/drafts/config-settings-window.md b/backlog/drafts/config-settings-window.md new file mode 100644 index 00000000..254f9beb --- /dev/null +++ b/backlog/drafts/config-settings-window.md @@ -0,0 +1,15 @@ +# Config Settings Window + +Status: draft +Owner: Kyle Yasuda +Created: 2026-05-17 + +## Goal + +Add a dedicated configuration window that groups settings by user workflow while saving back to the existing `config.jsonc` paths. + +## Notes + +- Full current config surface, excluding legacy/ignored compatibility keys. +- Preserve JSONC comments/formatting when saving. +- Surface hot-reload vs restart-required results. diff --git a/bun.lock b/bun.lock index c58076c0..971ad015 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "jsonc-parser": "^3.3.1", "koffi": "^2.15.6", "libsql": "^0.5.22", + "vscode-json-languageservice": "^5.7.2", "ws": "^8.19.0", }, "devDependencies": { @@ -188,6 +189,8 @@ "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], + "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + "@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="], @@ -722,6 +725,14 @@ "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], + "vscode-json-languageservice": ["vscode-json-languageservice@5.7.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], diff --git a/changes/config-settings-window.md b/changes/config-settings-window.md new file mode 100644 index 00000000..26d8d662 --- /dev/null +++ b/changes/config-settings-window.md @@ -0,0 +1,6 @@ +type: added +area: config + +- Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer config`. +- Fixed the Configuration window preload so launcher-opened windows can initialize even when Electron sandboxing is active. +- Kept config-window startup lightweight by skipping AniList token refresh and automatic update polling. diff --git a/changes/native-updater-crash.md b/changes/native-updater-crash.md index 669a10c1..4bd44b74 100644 --- a/changes/native-updater-crash.md +++ b/changes/native-updater-crash.md @@ -1,4 +1,6 @@ type: fixed area: updates -- Kept signed macOS app updates on the native updater path while preventing eager Squirrel install checks before the user confirms restart. +- Restored the standard macOS `electron-updater`/Squirrel update path and routed supplemental GitHub updater requests through Electron networking instead of Node fetch. +- macOS update checks now skip local build-output apps outside Applications before touching Squirrel, and macOS tray checks no longer perform the supplemental GitHub asset lookup. +- macOS `electron-updater` metadata and full ZIP downloads now use `/usr/bin/curl` under the hood to avoid the Electron network crash seen during tray update checks while preserving Squirrel installation. diff --git a/config.example.jsonc b/config.example.jsonc index 9d2d3c68..705fc2aa 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -10,7 +10,7 @@ // Overlay Auto-Start // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. // ========================================== - "auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false + "auto_start_overlay": false, // Auto-start the subtitle overlay window when SubMiner launches. Values: true | false // ========================================== // Texthooker Server @@ -18,7 +18,7 @@ // ========================================== "texthooker": { "launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false - "openBrowser": false // Open browser setting. Values: true | false + "openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false }, // Configure texthooker startup launch and browser opening behavior. // ========================================== @@ -174,24 +174,24 @@ // Hot-reload: shortcut changes apply live and update the session help modal on reopen. // ========================================== "shortcuts": { - "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting. - "copySubtitle": "CommandOrControl+C", // Copy subtitle setting. - "copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting. - "updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting. - "triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting. - "triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting. - "mineSentence": "CommandOrControl+S", // Mine sentence setting. - "mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting. + "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable. + "copySubtitle": "CommandOrControl+C", // Accelerator that copies the current subtitle line to the clipboard. + "copySubtitleMultiple": "CommandOrControl+Shift+C", // Accelerator that copies consecutive subtitle lines while the multi-copy window stays open. + "updateLastCardFromClipboard": "CommandOrControl+V", // Accelerator that updates the last mined Anki card using the current clipboard contents. + "triggerFieldGrouping": "CommandOrControl+G", // Accelerator that triggers Kiku field grouping on duplicate cards. + "triggerSubsync": "Ctrl+Alt+S", // Accelerator that triggers subsync against the active subtitle file. + "mineSentence": "CommandOrControl+S", // Accelerator that mines the current sentence as a new Anki card. + "mineSentenceMultiple": "CommandOrControl+Shift+S", // Accelerator that mines consecutive sentences while the multi-mine window stays open. "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. - "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. - "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. - "openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting. - "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. - "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. - "openSessionHelp": "CommandOrControl+Slash", // Open session help setting. - "openControllerSelect": "Alt+C", // Open controller select setting. - "openControllerDebug": "Alt+Shift+C", // Open controller debug setting. - "toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting. + "toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility. + "markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card. + "openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal. + "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. + "openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal. + "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. + "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. + "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. + "toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -328,9 +328,9 @@ // Hot-reload: defaultMode updates live while SubMiner is running. // ========================================== "secondarySub": { - "secondarySubLanguages": [], // Secondary sub languages setting. - "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false - "defaultMode": "hover" // Default mode setting. + "secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available. + "autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false + "defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover }, // Dual subtitle track options. // ========================================== @@ -339,9 +339,9 @@ // ========================================== "subsync": { "defaultMode": "auto", // Subsync default mode. Values: auto | manual - "alass_path": "", // Alass path setting. - "ffsubsync_path": "", // Ffsubsync path setting. - "ffmpeg_path": "", // Ffmpeg path setting. + "alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH. + "ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH. + "ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH. "replace": true // Replace the active subtitle file when sync completes. Values: true | false }, // Subsync engine and executable paths. @@ -350,7 +350,7 @@ // Initial vertical subtitle position from the bottom. // ========================================== "subtitlePosition": { - "yPercent": 10 // Y percent setting. + "yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. }, // Initial vertical subtitle position from the bottom. // ========================================== @@ -454,9 +454,9 @@ "enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false "apiKey": "", // Static API key for the shared OpenAI-compatible AI provider. "apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key. - "model": "openai/gpt-4o-mini", // Model setting. + "model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider. "baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider. - "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting. + "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests. "requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests. }, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. @@ -469,7 +469,7 @@ // ========================================== "ankiConnect": { "enabled": true, // Enable AnkiConnect integration. Values: true | false - "url": "http://127.0.0.1:8765", // Url setting. + "url": "http://127.0.0.1:8765", // Base URL of the AnkiConnect HTTP server. "pollingRate": 3000, // Polling interval in milliseconds. "proxy": { "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false @@ -482,11 +482,11 @@ ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "fields": { "word": "Expression", // Card field for the mined word or expression text. - "audio": "ExpressionAudio", // Audio setting. - "image": "Picture", // Image setting. - "sentence": "Sentence", // Sentence setting. - "miscInfo": "MiscInfo", // Misc info setting. - "translation": "SelectionText" // Translation setting. + "audio": "ExpressionAudio", // Card field that receives generated sentence audio. + "image": "Picture", // Card field that receives the captured screenshot or animated image. + "sentence": "Sentence", // Card field that receives the source sentence text. + "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). + "translation": "SelectionText" // Card field that receives the current selection or translated text. }, // Fields setting. "ai": { "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false @@ -494,18 +494,18 @@ "systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows. }, // Ai setting. "media": { - "generateAudio": true, // Generate audio setting. Values: true | false - "generateImage": true, // Generate image setting. Values: true | false - "imageType": "static", // Image type setting. - "imageFormat": "jpg", // Image format setting. - "imageQuality": 92, // Image quality setting. - "animatedFps": 10, // Animated fps setting. - "animatedMaxWidth": 640, // Animated max width setting. - "animatedCrf": 35, // Animated crf setting. + "generateAudio": true, // Generate sentence audio for mined cards. Values: true | false + "generateImage": true, // Generate screenshot or animated image for mined cards. Values: true | false + "imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif + "imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp + "imageQuality": 92, // Quality (0-100) used for lossy static image encoders. + "animatedFps": 10, // Target frame rate for animated AVIF captures. + "animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures. + "animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files. "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false - "audioPadding": 0.5, // Audio padding setting. - "fallbackDuration": 3, // Fallback duration setting. - "maxMediaDuration": 30 // Max media duration setting. + "audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio. + "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. + "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. }, // Media setting. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false @@ -516,11 +516,11 @@ "color": "#a6da95" // Color used for known-word highlights. }, // Known words setting. "behavior": { - "overwriteAudio": true, // Overwrite audio setting. Values: true | false - "overwriteImage": true, // Overwrite image setting. Values: true | false - "mediaInsertMode": "append", // Media insert mode setting. - "highlightWord": true, // Highlight word setting. Values: true | false - "notificationType": "osd", // Notification type setting. + "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false + "overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false + "mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend + "highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false + "notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { @@ -528,16 +528,16 @@ "nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight. }, // N plus one setting. "metadata": { - "pattern": "[SubMiner] %f (%t)" // Pattern setting. + "pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). }, // Metadata setting. "isLapis": { - "enabled": false, // Enabled setting. Values: true | false - "sentenceCardModel": "Japanese sentences" // Sentence card model setting. + "enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false + "sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards. }, // Is lapis setting. "isKiku": { - "enabled": false, // Enabled setting. Values: true | false + "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled - "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false + "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false } // Is kiku setting. }, // Automatic Anki updates and media generation options. @@ -546,7 +546,7 @@ // Jimaku API configuration and defaults. // ========================================== "jimaku": { - "apiBaseUrl": "https://jimaku.cc", // Api base url setting. + "apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API. "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none "maxEntryResults": 10 // Maximum Jimaku search results returned. }, // Jimaku API configuration and defaults. @@ -618,9 +618,9 @@ "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "username": "", // Default Jellyfin username used during CLI login. - "deviceId": "subminer", // Device id setting. - "clientName": "SubMiner", // Client name setting. - "clientVersion": "0.1.0", // Client version setting. + "deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal. + "clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal. + "clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 5fbcb9c7..81eb2dba 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -59,6 +59,28 @@ For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages). +### Configuration Window + +SubMiner also includes a dedicated **Configuration** window from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It groups settings by workflow instead of mirroring the raw config-file shape: + +- Viewing +- Mining & Anki +- Playback & Sources +- Input +- Integrations +- Tracking & App +- Advanced + +Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Viewing** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. + +The settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies. + +Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available. + +Some compatibility-only or ignored legacy keys are intentionally hidden from the normal field list, including legacy top-level Anki migration fields, old N+1 aliases, the removed YouTube subtitle-generation primary-language key, `anilist.characterDictionary.refreshTtlHours`, `anilist.characterDictionary.evictionPolicy`, `jellyfin.accessToken`, `jellyfin.userId`, and normal editing for `controller.buttonIndices`. Advanced/raw JSON editing remains the escape hatch for unsupported or legacy keys. + +Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window. + ### Hot-Reload Behavior SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically. @@ -1229,7 +1251,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner | `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | -Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. +Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime and are hidden from the configuration window. - On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=` on launcher/app invocations when needed. diff --git a/docs-site/installation.md b/docs-site/installation.md index 3f7f03a4..b2232ba9 100644 --- a/docs-site/installation.md +++ b/docs-site/installation.md @@ -174,9 +174,9 @@ subminer -u subminer --update ``` -SubMiner verifies launcher and Linux rofi theme downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead. +SubMiner verifies AppImage, launcher, and Linux rofi theme downloads against `SHA256SUMS.txt`. If the AppImage or launcher is installed in a protected path, SubMiner does not elevate itself; it shows the exact sudo command to run instead. -On Linux, `subminer -u` performs this update from the launcher process, so it does not need to start or IPC into the tray app. +On Linux, `subminer -u` performs the AppImage update from the launcher process, so it does not need to start or IPC into the tray app. ### From Source @@ -206,6 +206,8 @@ Download the **DMG** artifact from [GitHub Releases](https://github.com/ksyasuda A **ZIP** artifact is also available as a fallback — unzip and drag `SubMiner.app` into `/Applications`. +After the first updater-enabled install, tray update checks can update the macOS app automatically through Electron's standard macOS updater. The updater uses the release ZIP as its payload even when the DMG remains the normal first-install artifact. + Install dependencies using Homebrew: ```bash diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 9d2d3c68..705fc2aa 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -10,7 +10,7 @@ // Overlay Auto-Start // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. // ========================================== - "auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false + "auto_start_overlay": false, // Auto-start the subtitle overlay window when SubMiner launches. Values: true | false // ========================================== // Texthooker Server @@ -18,7 +18,7 @@ // ========================================== "texthooker": { "launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false - "openBrowser": false // Open browser setting. Values: true | false + "openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false }, // Configure texthooker startup launch and browser opening behavior. // ========================================== @@ -174,24 +174,24 @@ // Hot-reload: shortcut changes apply live and update the session help modal on reopen. // ========================================== "shortcuts": { - "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting. - "copySubtitle": "CommandOrControl+C", // Copy subtitle setting. - "copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting. - "updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting. - "triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting. - "triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting. - "mineSentence": "CommandOrControl+S", // Mine sentence setting. - "mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting. + "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable. + "copySubtitle": "CommandOrControl+C", // Accelerator that copies the current subtitle line to the clipboard. + "copySubtitleMultiple": "CommandOrControl+Shift+C", // Accelerator that copies consecutive subtitle lines while the multi-copy window stays open. + "updateLastCardFromClipboard": "CommandOrControl+V", // Accelerator that updates the last mined Anki card using the current clipboard contents. + "triggerFieldGrouping": "CommandOrControl+G", // Accelerator that triggers Kiku field grouping on duplicate cards. + "triggerSubsync": "Ctrl+Alt+S", // Accelerator that triggers subsync against the active subtitle file. + "mineSentence": "CommandOrControl+S", // Accelerator that mines the current sentence as a new Anki card. + "mineSentenceMultiple": "CommandOrControl+Shift+S", // Accelerator that mines consecutive sentences while the multi-mine window stays open. "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. - "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. - "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. - "openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting. - "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. - "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. - "openSessionHelp": "CommandOrControl+Slash", // Open session help setting. - "openControllerSelect": "Alt+C", // Open controller select setting. - "openControllerDebug": "Alt+Shift+C", // Open controller debug setting. - "toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting. + "toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility. + "markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card. + "openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal. + "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. + "openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal. + "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. + "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. + "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. + "toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -328,9 +328,9 @@ // Hot-reload: defaultMode updates live while SubMiner is running. // ========================================== "secondarySub": { - "secondarySubLanguages": [], // Secondary sub languages setting. - "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false - "defaultMode": "hover" // Default mode setting. + "secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available. + "autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false + "defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover }, // Dual subtitle track options. // ========================================== @@ -339,9 +339,9 @@ // ========================================== "subsync": { "defaultMode": "auto", // Subsync default mode. Values: auto | manual - "alass_path": "", // Alass path setting. - "ffsubsync_path": "", // Ffsubsync path setting. - "ffmpeg_path": "", // Ffmpeg path setting. + "alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH. + "ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH. + "ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH. "replace": true // Replace the active subtitle file when sync completes. Values: true | false }, // Subsync engine and executable paths. @@ -350,7 +350,7 @@ // Initial vertical subtitle position from the bottom. // ========================================== "subtitlePosition": { - "yPercent": 10 // Y percent setting. + "yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. }, // Initial vertical subtitle position from the bottom. // ========================================== @@ -454,9 +454,9 @@ "enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false "apiKey": "", // Static API key for the shared OpenAI-compatible AI provider. "apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key. - "model": "openai/gpt-4o-mini", // Model setting. + "model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider. "baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider. - "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting. + "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests. "requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests. }, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. @@ -469,7 +469,7 @@ // ========================================== "ankiConnect": { "enabled": true, // Enable AnkiConnect integration. Values: true | false - "url": "http://127.0.0.1:8765", // Url setting. + "url": "http://127.0.0.1:8765", // Base URL of the AnkiConnect HTTP server. "pollingRate": 3000, // Polling interval in milliseconds. "proxy": { "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false @@ -482,11 +482,11 @@ ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "fields": { "word": "Expression", // Card field for the mined word or expression text. - "audio": "ExpressionAudio", // Audio setting. - "image": "Picture", // Image setting. - "sentence": "Sentence", // Sentence setting. - "miscInfo": "MiscInfo", // Misc info setting. - "translation": "SelectionText" // Translation setting. + "audio": "ExpressionAudio", // Card field that receives generated sentence audio. + "image": "Picture", // Card field that receives the captured screenshot or animated image. + "sentence": "Sentence", // Card field that receives the source sentence text. + "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). + "translation": "SelectionText" // Card field that receives the current selection or translated text. }, // Fields setting. "ai": { "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false @@ -494,18 +494,18 @@ "systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows. }, // Ai setting. "media": { - "generateAudio": true, // Generate audio setting. Values: true | false - "generateImage": true, // Generate image setting. Values: true | false - "imageType": "static", // Image type setting. - "imageFormat": "jpg", // Image format setting. - "imageQuality": 92, // Image quality setting. - "animatedFps": 10, // Animated fps setting. - "animatedMaxWidth": 640, // Animated max width setting. - "animatedCrf": 35, // Animated crf setting. + "generateAudio": true, // Generate sentence audio for mined cards. Values: true | false + "generateImage": true, // Generate screenshot or animated image for mined cards. Values: true | false + "imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif + "imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp + "imageQuality": 92, // Quality (0-100) used for lossy static image encoders. + "animatedFps": 10, // Target frame rate for animated AVIF captures. + "animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures. + "animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files. "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false - "audioPadding": 0.5, // Audio padding setting. - "fallbackDuration": 3, // Fallback duration setting. - "maxMediaDuration": 30 // Max media duration setting. + "audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio. + "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. + "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. }, // Media setting. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false @@ -516,11 +516,11 @@ "color": "#a6da95" // Color used for known-word highlights. }, // Known words setting. "behavior": { - "overwriteAudio": true, // Overwrite audio setting. Values: true | false - "overwriteImage": true, // Overwrite image setting. Values: true | false - "mediaInsertMode": "append", // Media insert mode setting. - "highlightWord": true, // Highlight word setting. Values: true | false - "notificationType": "osd", // Notification type setting. + "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false + "overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false + "mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend + "highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false + "notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { @@ -528,16 +528,16 @@ "nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight. }, // N plus one setting. "metadata": { - "pattern": "[SubMiner] %f (%t)" // Pattern setting. + "pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). }, // Metadata setting. "isLapis": { - "enabled": false, // Enabled setting. Values: true | false - "sentenceCardModel": "Japanese sentences" // Sentence card model setting. + "enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false + "sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards. }, // Is lapis setting. "isKiku": { - "enabled": false, // Enabled setting. Values: true | false + "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled - "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false + "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false } // Is kiku setting. }, // Automatic Anki updates and media generation options. @@ -546,7 +546,7 @@ // Jimaku API configuration and defaults. // ========================================== "jimaku": { - "apiBaseUrl": "https://jimaku.cc", // Api base url setting. + "apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API. "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none "maxEntryResults": 10 // Maximum Jimaku search results returned. }, // Jimaku API configuration and defaults. @@ -618,9 +618,9 @@ "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "username": "", // Default Jellyfin username used during CLI login. - "deviceId": "subminer", // Device id setting. - "clientName": "SubMiner", // Client name setting. - "clientVersion": "0.1.0", // Client version setting. + "deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal. + "clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal. + "clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0cda2672..873ea24d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -84,5 +84,8 @@ Notes: - AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled. +- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer. +- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks. +- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed. - The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code. - Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior. diff --git a/docs/plans/config-settings-window.md b/docs/plans/config-settings-window.md new file mode 100644 index 00000000..38bddc6a --- /dev/null +++ b/docs/plans/config-settings-window.md @@ -0,0 +1,70 @@ +# Config Settings Window + +read_when: changing config UI, config save behavior, or config docs + +## Intent + +Add a dedicated Electron settings window for editing canonical config values without exposing the historical layout mistakes in `config.jsonc`. + +The UI groups options by workflow: + +- Viewing +- Mining & Anki +- Playback & Sources +- Input +- Integrations +- Tracking & App +- Advanced + +Each field maps back to its current raw config path. The presentation layer must stay separate from generated config-template sections. + +## Sources + +- Canonical defaults: `DEFAULT_CONFIG` +- Existing option descriptions/enums: `CONFIG_OPTION_REGISTRY` +- UI registry: `src/config/settings/registry.ts` +- JSONC save path: `src/config/settings/jsonc-edit.ts` +- Window runtime: `src/main/runtime/config-settings-window.ts` + +## Save Contract + +Settings writes use `jsonc-parser.modify`, not `JSON.stringify`. + +Required behavior: + +- Preserve comments, trailing commas, unrelated keys, and hidden legacy keys. +- Reset removes the explicit path so defaults resolve normally. +- Validate the candidate config before writing. +- Reject warnings caused by modified fields. +- Preserve unrelated existing warnings and return them in the snapshot. +- Write atomically, reload `ConfigService`, classify with existing hot-reload logic, and apply live changes where supported. +- Never return secret values to the renderer; snapshots only expose configured/not-configured state. + +## Hidden Compatibility Keys + +Do not expose these as first-class controls: + +- `ankiConnect.deck` +- Legacy top-level Anki migration fields such as `wordField`, `audioField`, media-generation aliases, and behavior aliases +- Legacy `ankiConnect.nPlusOne.*` aliases except canonical `nPlusOne.nPlusOne` and `nPlusOne.minSentenceWords` +- Deprecated Lapis sentence-card fields +- `youtubeSubgen.primarySubLanguages` +- `anilist.characterDictionary.refreshTtlHours` +- `anilist.characterDictionary.evictionPolicy` +- `jellyfin.accessToken` +- `jellyfin.userId` +- `controller.buttonIndices` as a normal editable field + +## Verification + +Minimum targeted checks: + +- `bun test src/config/settings/registry.test.ts src/config/settings/jsonc-edit.test.ts src/settings/settings-model.test.ts src/main/runtime/config-settings-window.test.ts` +- `bun run test:config` +- `bun run typecheck` +- `bun run build` + +If docs changed: + +- `bun run docs:test` +- `bun run docs:build` diff --git a/launcher/commands/app-command.ts b/launcher/commands/app-command.ts index 62a49ac6..4dd4c059 100644 --- a/launcher/commands/app-command.ts +++ b/launcher/commands/app-command.ts @@ -3,7 +3,14 @@ import type { LauncherCommandContext } from './context.js'; export function runAppPassthroughCommand(context: LauncherCommandContext): boolean { const { args, appPath } = context; - if (!args.appPassthrough || !appPath) { + if (!appPath) { + return false; + } + if (args.configSettings) { + runAppCommandWithInherit(appPath, ['--config']); + return true; + } + if (!args.appPassthrough) { return false; } runAppCommandWithInherit(appPath, args.appArgs); diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index fac501fa..b01d9d48 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -53,6 +53,7 @@ function createContext(): LauncherCommandContext { doctor: false, doctorRefreshKnownWords: false, version: false, + configSettings: false, configPath: false, configShow: false, mpvIdle: false, diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index a3d6b5f6..a95daed0 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -159,6 +159,41 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { assert.equal(parsed.logLevel, 'warn'); }); +test('applyInvocationsToArgs maps bare config invocation to settings window', () => { + const parsed = createDefaultArgs({}); + + applyInvocationsToArgs(parsed, { + jellyfinInvocation: null, + configInvocation: { + action: undefined, + }, + 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(parsed.configSettings, true); + assert.equal(parsed.configPath, false); +}); + test('applyInvocationsToArgs maps texthooker browser-open request', () => { const parsed = createDefaultArgs({}); diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 3e08bc5e..4fa26131 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -158,6 +158,7 @@ export function createDefaultArgs( doctorRefreshKnownWords: false, version: false, update: false, + configSettings: false, configPath: false, configShow: false, mpvIdle: false, @@ -221,6 +222,7 @@ export function applyRootOptionsToArgs( if (options.rofi === true) parsed.useRofi = true; if (options.update === true) parsed.update = true; if (options.version === true) parsed.version = true; + if (options.config === true) parsed.configSettings = true; if (options.startOverlay === true) parsed.autoStartOverlay = true; if (options.texthooker === false) parsed.useTexthooker = false; if (typeof options.args === 'string') parsed.mpvArgs = options.args; @@ -308,8 +310,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations if (invocations.configInvocation.logLevel) { parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel); } - const action = (invocations.configInvocation.action || 'path').toLowerCase(); - if (action === 'path') parsed.configPath = true; + const action = (invocations.configInvocation.action || '').toLowerCase(); + if (!action) parsed.configSettings = true; + else if (action === 'path') parsed.configPath = true; else if (action === 'show') parsed.configShow = true; else fail(`Unknown config action: ${invocations.configInvocation.action}`); } diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 62548d9f..d0bf8f57 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -15,7 +15,7 @@ export interface JellyfinInvocation { } export interface CommandActionInvocation { - action: string; + action?: string; logLevel?: string; } @@ -58,6 +58,7 @@ function applyRootOptions(program: Command): void { .option('--start', 'Explicitly start overlay') .option('--log-level ', 'Log level') .option('-v, --version', 'Show SubMiner version') + .option('--config', 'Open configuration window') .option('-u, --update', 'Check for updates') .option('-R, --rofi', 'Use rofi picker') .option('-S, --start-overlay', 'Auto-start overlay') @@ -293,9 +294,9 @@ export function parseCliPrograms( commandProgram .command('config') .description('Config helpers') - .argument('[action]', 'path|show', 'path') + .argument('[action]', 'path|show') .option('--log-level ', 'Log level') - .action((action: string, options: Record) => { + .action((action: string | undefined, options: Record) => { configInvocation = { action, logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, diff --git a/launcher/main.test.ts b/launcher/main.test.ts index a80db49e..8adc955e 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -232,6 +232,54 @@ test('doctor refresh-known-words forwards app refresh command without requiring }); }); +test('launcher config option forwards app configuration window command', () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const appPath = path.join(root, 'fake-subminer.sh'); + const capturePath = path.join(root, 'captured-args.txt'); + fs.writeFileSync( + appPath, + '#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', + ); + fs.chmodSync(appPath, 0o755); + + const env = { + ...makeTestEnv(homeDir, xdgConfigHome), + SUBMINER_APPIMAGE_PATH: appPath, + SUBMINER_TEST_CAPTURE: capturePath, + }; + const result = runLauncher(['--config'], env); + + assert.equal(result.status, 0); + assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n'); + }); +}); + +test('launcher config command forwards app configuration window command', () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const appPath = path.join(root, 'fake-subminer.sh'); + const capturePath = path.join(root, 'captured-args.txt'); + fs.writeFileSync( + appPath, + '#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', + ); + fs.chmodSync(appPath, 0o755); + + const env = { + ...makeTestEnv(homeDir, xdgConfigHome), + SUBMINER_APPIMAGE_PATH: appPath, + SUBMINER_TEST_CAPTURE: capturePath, + }; + const result = runLauncher(['config'], env); + + assert.equal(result.status, 0); + assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n'); + }); +}); + test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 4c59201b..37db175f 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -530,6 +530,7 @@ function makeArgs(overrides: Partial = {}): Args { doctor: false, doctorRefreshKnownWords: false, version: false, + configSettings: false, configPath: false, configShow: false, mpvIdle: false, diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index e4b40cc9..e704f840 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -57,6 +57,12 @@ test('parseArgs captures mpv args string', () => { assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"'); }); +test('parseArgs maps root config window option', () => { + const parsed = parseArgs(['--config'], 'subminer', {}); + + assert.equal(parsed.configSettings, true); +}); + test('parseArgs maps root update flags without conflicting with jellyfin username', () => { const shortParsed = parseArgs(['-u'], 'subminer', {}); const longParsed = parseArgs(['--update'], 'subminer', {}); @@ -101,6 +107,33 @@ test('parseArgs maps config show action', () => { assert.equal(parsed.configPath, false); }); +test('parseArgs maps bare config command to settings window', () => { + const parsed = parseArgs(['config'], 'subminer', {}); + + assert.equal(parsed.configSettings, true); + assert.equal(parsed.configPath, false); + assert.equal(parsed.configShow, false); +}); + +test('parseArgs maps config path action to config path output', () => { + const parsed = parseArgs(['config', 'path'], 'subminer', {}); + + assert.equal(parsed.configPath, true); + assert.equal(parsed.configSettings, false); +}); + +test('parseArgs rejects removed config open and launch actions', () => { + const openExit = withProcessExitIntercept(() => { + parseArgs(['config', 'open'], 'subminer', {}); + }); + const exit = withProcessExitIntercept(() => { + parseArgs(['config', 'launch'], 'subminer', {}); + }); + + assert.equal(openExit.code, 1); + assert.equal(exit.code, 1); +}); + test('parseArgs maps mpv idle action', () => { const parsed = parseArgs(['mpv', 'idle'], 'subminer', {}); diff --git a/launcher/types.ts b/launcher/types.ts index d2cc320e..9751a157 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -136,6 +136,7 @@ export interface Args { doctorRefreshKnownWords: boolean; version: boolean; update?: boolean; + configSettings: boolean; configPath: boolean; configShow: boolean; mpvIdle: boolean; diff --git a/main.js b/main.js new file mode 100644 index 00000000..c3c6c962 --- /dev/null +++ b/main.js @@ -0,0 +1,4748 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +/* + SubMiner - All-in-one sentence mining overlay + Copyright (C) 2024 sudacode + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +const electron_1 = require("electron"); +const controller_config_update_js_1 = require("./main/controller-config-update.js"); +const playlist_browser_open_1 = require("./main/runtime/playlist-browser-open"); +const discord_rpc_client_js_1 = require("./main/runtime/discord-rpc-client.js"); +const linux_mpv_fullscreen_overlay_refresh_1 = require("./main/runtime/linux-mpv-fullscreen-overlay-refresh"); +const config_1 = require("./ai/config"); +function getPasswordStoreArg(argv) { + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg?.startsWith('--password-store')) { + continue; + } + if (arg === '--password-store') { + const value = argv[i + 1]; + if (value && !value.startsWith('--')) { + return value; + } + return null; + } + const [prefix, value] = arg.split('=', 2); + if (prefix === '--password-store' && value && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} +function normalizePasswordStoreArg(value) { + const normalized = value.trim(); + if (normalized.toLowerCase() === 'gnome') { + return 'gnome-libsecret'; + } + return normalized; +} +function getDefaultPasswordStore() { + return 'gnome-libsecret'; +} +electron_1.protocol.registerSchemesAsPrivileged([ + { + scheme: 'chrome-extension', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + bypassCSP: true, + }, + }, +]); +const fs = __importStar(require("fs")); +const node_child_process_1 = require("node:child_process"); +const os = __importStar(require("os")); +const path = __importStar(require("path")); +const mecab_tokenizer_1 = require("./mecab-tokenizer"); +const anki_integration_1 = require("./anki-integration"); +const subtitle_timing_tracker_1 = require("./subtitle-timing-tracker"); +const runtime_options_1 = require("./runtime-options"); +const utils_1 = require("./jimaku/utils"); +const logger_1 = require("./logger"); +const window_trackers_1 = require("./window-trackers"); +const windows_helper_1 = require("./window-trackers/windows-helper"); +const args_1 = require("./cli/args"); +const help_1 = require("./cli/help"); +const contracts_1 = require("./shared/ipc/contracts"); +const startup_mode_flags_1 = require("./main/runtime/startup-mode-flags"); +const config_validation_1 = require("./main/config-validation"); +const anilist_1 = require("./main/runtime/domains/anilist"); +const watch_threshold_1 = require("./shared/watch-threshold"); +const jellyfin_1 = require("./main/runtime/domains/jellyfin"); +const overlay_1 = require("./main/runtime/domains/overlay"); +const startup_1 = require("./main/runtime/domains/startup"); +const mpv_1 = require("./main/runtime/domains/mpv"); +const mining_1 = require("./main/runtime/domains/mining"); +const utils_2 = require("./core/utils"); +const setup_state_1 = require("./shared/setup-state"); +const services_1 = require("./core/services"); +const generate_1 = require("./core/services/youtube/generate"); +const playback_resolve_1 = require("./core/services/youtube/playback-resolve"); +const track_probe_1 = require("./core/services/youtube/track-probe"); +const stats_server_1 = require("./core/services/stats-server"); +const stats_window_js_1 = require("./core/services/stats-window.js"); +const stats_window_js_2 = require("./core/services/stats-window.js"); +const first_run_setup_service_1 = require("./main/runtime/first-run-setup-service"); +const youtube_flow_1 = require("./main/runtime/youtube-flow"); +const youtube_playback_runtime_1 = require("./main/runtime/youtube-playback-runtime"); +const youtube_primary_subtitle_notification_1 = require("./main/runtime/youtube-primary-subtitle-notification"); +const autoplay_ready_gate_1 = require("./main/runtime/autoplay-ready-gate"); +const local_subtitle_selection_1 = require("./main/runtime/local-subtitle-selection"); +const first_run_setup_window_1 = require("./main/runtime/first-run-setup-window"); +const first_run_setup_plugin_1 = require("./main/runtime/first-run-setup-plugin"); +const windows_mpv_shortcuts_1 = require("./main/runtime/windows-mpv-shortcuts"); +const command_line_launcher_1 = require("./main/runtime/command-line-launcher"); +const windows_mpv_launch_1 = require("./main/runtime/windows-mpv-launch"); +const jellyfin_remote_connection_1 = require("./main/runtime/jellyfin-remote-connection"); +const jellyfin_tray_discovery_1 = require("./main/runtime/jellyfin-tray-discovery"); +const youtube_playback_launch_1 = require("./main/runtime/youtube-playback-launch"); +const startup_tray_policy_1 = require("./main/runtime/startup-tray-policy"); +const immersion_startup_1 = require("./main/runtime/immersion-startup"); +const immersion_startup_main_deps_1 = require("./main/runtime/immersion-startup-main-deps"); +const stats_cli_command_1 = require("./main/runtime/stats-cli-command"); +const stats_daemon_1 = require("./main/runtime/stats-daemon"); +const stats_server_routing_1 = require("./main/runtime/stats-server-routing"); +const legacy_vocabulary_pos_1 = require("./core/services/immersion-tracker/legacy-vocabulary-pos"); +const anilist_update_queue_1 = require("./core/services/anilist/anilist-update-queue"); +const anilist_updater_1 = require("./core/services/anilist/anilist-updater"); +const cover_art_fetcher_1 = require("./core/services/anilist/cover-art-fetcher"); +const rate_limiter_1 = require("./core/services/anilist/rate-limiter"); +const jellyfin_token_store_1 = require("./core/services/jellyfin-token-store"); +const runtime_options_ipc_1 = require("./core/services/runtime-options-ipc"); +const anilist_token_store_1 = require("./core/services/anilist/anilist-token-store"); +const session_bindings_1 = require("./core/services/session-bindings"); +const session_actions_1 = require("./core/services/session-actions"); +const shortcuts_1 = require("./main/runtime/domains/shortcuts"); +const registry_1 = require("./main/runtime/registry"); +const overlay_mpv_sub_visibility_1 = require("./main/runtime/overlay-mpv-sub-visibility"); +const composers_1 = require("./main/runtime/composers"); +const overlay_window_runtime_handlers_1 = require("./main/runtime/overlay-window-runtime-handlers"); +const startup_2 = require("./main/startup"); +const startup_lifecycle_1 = require("./main/startup-lifecycle"); +const early_single_instance_1 = require("./main/early-single-instance"); +const ipc_mpv_command_1 = require("./main/ipc-mpv-command"); +const ipc_runtime_1 = require("./main/ipc-runtime"); +const dependencies_1 = require("./main/dependencies"); +const services_2 = require("./main/boot/services"); +const cli_runtime_1 = require("./main/cli-runtime"); +const overlay_runtime_1 = require("./main/overlay-runtime"); +const overlay_modal_input_state_1 = require("./main/runtime/overlay-modal-input-state"); +const youtube_picker_open_1 = require("./main/runtime/youtube-picker-open"); +const runtime_options_open_1 = require("./main/runtime/runtime-options-open"); +const jimaku_open_1 = require("./main/runtime/jimaku-open"); +const subsync_open_1 = require("./main/runtime/subsync-open"); +const session_help_open_1 = require("./main/runtime/session-help-open"); +const character_dictionary_open_1 = require("./main/runtime/character-dictionary-open"); +const controller_select_open_1 = require("./main/runtime/controller-select-open"); +const controller_debug_open_1 = require("./main/runtime/controller-debug-open"); +const playlist_browser_ipc_1 = require("./main/runtime/playlist-browser-ipc"); +const session_bindings_artifact_1 = require("./main/runtime/session-bindings-artifact"); +const overlay_shortcuts_runtime_1 = require("./main/overlay-shortcuts-runtime"); +const frequency_dictionary_runtime_1 = require("./main/frequency-dictionary-runtime"); +const jlpt_runtime_1 = require("./main/jlpt-runtime"); +const media_runtime_1 = require("./main/media-runtime"); +const overlay_visibility_runtime_1 = require("./main/overlay-visibility-runtime"); +const discord_presence_runtime_1 = require("./main/runtime/discord-presence-runtime"); +const character_dictionary_runtime_1 = require("./main/character-dictionary-runtime"); +const character_dictionary_auto_sync_1 = require("./main/runtime/character-dictionary-auto-sync"); +const character_dictionary_auto_sync_completion_1 = require("./main/runtime/character-dictionary-auto-sync-completion"); +const character_dictionary_auto_sync_notifications_1 = require("./main/runtime/character-dictionary-auto-sync-notifications"); +const current_media_tokenization_gate_1 = require("./main/runtime/current-media-tokenization-gate"); +const startup_osd_sequencer_1 = require("./main/runtime/startup-osd-sequencer"); +const app_updater_1 = require("./main/runtime/update/app-updater"); +const fetch_adapter_1 = require("./main/runtime/update/fetch-adapter"); +const release_assets_1 = require("./main/runtime/update/release-assets"); +const launcher_updater_1 = require("./main/runtime/update/launcher-updater"); +const update_notifications_1 = require("./main/runtime/update/update-notifications"); +const update_dialogs_1 = require("./main/runtime/update/update-dialogs"); +const update_cli_command_1 = require("./main/runtime/update/update-cli-command"); +const update_service_1 = require("./main/runtime/update/update-service"); +const support_assets_1 = require("./main/runtime/update/support-assets"); +const subtitle_prefetch_runtime_1 = require("./main/runtime/subtitle-prefetch-runtime"); +const setup_window_factory_1 = require("./main/runtime/setup-window-factory"); +const config_settings_window_1 = require("./main/runtime/config-settings-window"); +const config_settings_save_1 = require("./main/runtime/config-settings-save"); +const youtube_playback_1 = require("./main/runtime/youtube-playback"); +const yomitan_profile_policy_1 = require("./main/runtime/yomitan-profile-policy"); +const yomitan_read_only_log_1 = require("./main/runtime/yomitan-read-only-log"); +const yomitan_anki_server_1 = require("./main/runtime/yomitan-anki-server"); +const state_1 = require("./main/state"); +const anilist_url_guard_1 = require("./main/anilist-url-guard"); +const config_2 = require("./config"); +const path_resolution_1 = require("./config/path-resolution"); +const jsonc_edit_1 = require("./config/settings/jsonc-edit"); +const registry_2 = require("./config/settings/registry"); +const subtitle_cue_parser_1 = require("./core/services/subtitle-cue-parser"); +const subtitle_prefetch_1 = require("./core/services/subtitle-prefetch"); +const subtitle_prefetch_source_1 = require("./main/runtime/subtitle-prefetch-source"); +const subtitle_prefetch_init_1 = require("./main/runtime/subtitle-prefetch-init"); +const character_dictionary_selection_1 = require("./main/character-dictionary-selection"); +const utils_3 = require("./subsync/utils"); +if (process.platform === 'linux') { + electron_1.app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); + const passwordStore = normalizePasswordStoreArg(getPasswordStoreArg(process.argv) ?? getDefaultPasswordStore()); + electron_1.app.commandLine.appendSwitch('password-store', passwordStore); + (0, logger_1.createLogger)('main').debug(`Applied --password-store ${passwordStore}`); +} +electron_1.app.setName('SubMiner'); +const DEFAULT_TEXTHOOKER_PORT = 5174; +const DEFAULT_MPV_LOG_FILE = (0, logger_1.resolveDefaultLogFilePath)({ + platform: process.platform, + homeDir: os.homedir(), + appDataDir: process.env.APPDATA, +}); +const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize'; +const ANILIST_SETUP_RESPONSE_TYPE = 'token'; +const ANILIST_DEFAULT_CLIENT_ID = '36084'; +const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/'; +const ANILIST_DEVELOPER_SETTINGS_URL = 'https://anilist.co/settings/developer'; +const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60; +const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000; +const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000; +const TRAY_TOOLTIP = 'SubMiner'; +let anilistMediaGuessRuntimeState = (0, state_1.createInitialAnilistMediaGuessRuntimeState)(); +let anilistUpdateInFlightState = (0, state_1.createInitialAnilistUpdateInFlightState)(); +const anilistAttemptedUpdateKeys = new Set(); +let anilistCachedAccessToken = null; +let jellyfinPlayQuitOnDisconnectArmed = false; +const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US'; +const JELLYFIN_TICKS_PER_SECOND = 10_000_000; +const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000; +const DISCORD_PRESENCE_APP_ID = '1475264834730856619'; +const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000; +const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000; +const YOUTUBE_MPV_CONNECT_TIMEOUT_MS = 3000; +const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000; +const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best'; +const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b'; +const MPV_JELLYFIN_DEFAULT_ARGS = [ + '--sub-auto=fuzzy', + '--sub-file-paths=.;subs;subtitles', + '--sid=auto', + '--secondary-sid=auto', + '--secondary-sub-visibility=no', + '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', + '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', +]; +let activeJellyfinRemotePlayback = null; +let jellyfinRemoteLastProgressAtMs = 0; +let jellyfinMpvAutoLaunchInFlight = null; +let backgroundWarmupsStarted = false; +let yomitanLoadInFlight = null; +let notifyAnilistTokenStoreWarning = () => { }; +const buildApplyJellyfinMpvDefaultsMainDepsHandler = (0, jellyfin_1.createBuildApplyJellyfinMpvDefaultsMainDepsHandler)({ + sendMpvCommandRuntime: (client, command) => (0, services_1.sendMpvCommandRuntime)(client, command), + jellyfinLangPref: JELLYFIN_LANG_PREF, +}); +const applyJellyfinMpvDefaultsMainDeps = buildApplyJellyfinMpvDefaultsMainDepsHandler(); +const applyJellyfinMpvDefaultsHandler = (0, jellyfin_1.createApplyJellyfinMpvDefaultsHandler)(applyJellyfinMpvDefaultsMainDeps); +function applyJellyfinMpvDefaults(client) { + applyJellyfinMpvDefaultsHandler(client); +} +const isDev = process.argv.includes('--dev') || process.argv.includes('--debug'); +const texthookerService = new services_1.Texthooker(() => { + const config = getResolvedConfig(); + const characterDictionaryEnabled = config.anilist.characterDictionary.enabled && + yomitanProfilePolicy.isCharacterDictionaryEnabled(); + const knownAndNPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.knownWords.highlightEnabled); + return { + enableKnownWordColoring: knownAndNPlusOneEnabled, + enableNPlusOneColoring: knownAndNPlusOneEnabled, + enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled, + enableFrequencyColoring: getRuntimeBooleanOption('subtitle.annotation.frequency', config.subtitleStyle.frequencyDictionary.enabled), + enableJlptColoring: getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt), + characterDictionaryEnabled, + knownWordColor: config.ankiConnect.knownWords.color, + nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne, + nameMatchColor: config.subtitleStyle.nameMatchColor, + hoverTokenColor: config.subtitleStyle.hoverTokenColor, + hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor, + jlptColors: config.subtitleStyle.jlptColors, + frequencyDictionary: { + singleColor: config.subtitleStyle.frequencyDictionary.singleColor, + bandedColors: config.subtitleStyle.frequencyDictionary.bandedColors, + }, + }; +}); +let syncOverlayShortcutsForModal = () => { }; +let syncOverlayVisibilityForModal = () => { }; +const buildGetDefaultSocketPathMainDepsHandler = (0, jellyfin_1.createBuildGetDefaultSocketPathMainDepsHandler)({ + platform: process.platform, +}); +const getDefaultSocketPathMainDeps = buildGetDefaultSocketPathMainDepsHandler(); +const getDefaultSocketPathHandler = (0, jellyfin_1.createGetDefaultSocketPathHandler)(getDefaultSocketPathMainDeps); +function getDefaultSocketPath() { + return getDefaultSocketPathHandler(); +} +const bootServices = (0, services_2.createMainBootServices)({ + platform: process.platform, + argv: process.argv, + appDataDir: process.env.APPDATA, + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + defaultMpvLogFile: DEFAULT_MPV_LOG_FILE, + envMpvLog: process.env.SUBMINER_MPV_LOG, + defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, + getDefaultSocketPath: () => getDefaultSocketPath(), + resolveConfigDir: path_resolution_1.resolveConfigDir, + existsSync: fs.existsSync, + mkdirSync: fs.mkdirSync, + joinPath: (...parts) => path.join(...parts), + app: electron_1.app, + shouldBypassSingleInstanceLock: () => (0, early_single_instance_1.shouldBypassSingleInstanceLockForArgv)(process.argv), + requestSingleInstanceLockEarly: () => (0, early_single_instance_1.requestSingleInstanceLockEarly)(electron_1.app), + registerSecondInstanceHandlerEarly: (listener) => { + (0, early_single_instance_1.registerSecondInstanceHandlerEarly)(electron_1.app, listener); + }, + onConfigStartupParseError: (error) => { + (0, config_validation_1.failStartupFromConfig)('SubMiner config parse error', (0, config_validation_1.buildConfigParseErrorDetails)(error.path, error.parseError), { + logError: (details) => console.error(details), + showErrorBox: (title, details) => electron_1.dialog.showErrorBox(title, details), + quit: () => requestAppQuit(), + }); + }, + createConfigService: (configDir) => new config_2.ConfigService(configDir), + createAnilistTokenStore: (targetPath) => (0, anilist_token_store_1.createAnilistTokenStore)(targetPath, { + info: (message) => console.info(message), + warn: (message, details) => console.warn(message, details), + error: (message, details) => console.error(message, details), + warnUser: (message) => notifyAnilistTokenStoreWarning(message), + }), + createJellyfinTokenStore: (targetPath) => (0, jellyfin_token_store_1.createJellyfinTokenStore)(targetPath, { + info: (message) => console.info(message), + warn: (message, details) => console.warn(message, details), + error: (message, details) => console.error(message, details), + }), + createAnilistUpdateQueue: (targetPath) => (0, anilist_update_queue_1.createAnilistUpdateQueue)(targetPath, { + info: (message) => console.info(message), + warn: (message, details) => console.warn(message, details), + error: (message, details) => console.error(message, details), + }), + createSubtitleWebSocket: () => new services_1.SubtitleWebSocket(), + createLogger: logger_1.createLogger, + createMainRuntimeRegistry: registry_1.createMainRuntimeRegistry, + createOverlayManager: services_1.createOverlayManager, + createOverlayModalInputState: overlay_modal_input_state_1.createOverlayModalInputState, + createOverlayContentMeasurementStore: ({ logger }) => { + const buildHandler = (0, overlay_1.createBuildOverlayContentMeasurementStoreMainDepsHandler)({ + now: () => Date.now(), + warn: (message) => logger.warn(message), + }); + return (0, services_1.createOverlayContentMeasurementStore)(buildHandler()); + }, + getSyncOverlayShortcutsForModal: () => syncOverlayShortcutsForModal, + getSyncOverlayVisibilityForModal: () => syncOverlayVisibilityForModal, + createOverlayModalRuntime: ({ overlayManager, overlayModalInputState }) => { + const buildHandler = (0, overlay_1.createBuildOverlayModalRuntimeMainDepsHandler)({ + getMainWindow: () => overlayManager.getMainWindow(), + getModalWindow: () => overlayManager.getModalWindow(), + createModalWindow: () => createModalWindow(), + getModalGeometry: () => getCurrentOverlayGeometry(), + setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry), + }); + return (0, overlay_runtime_1.createOverlayModalRuntimeService)(buildHandler(), { + onModalStateChange: (isActive) => overlayModalInputState.handleModalInputStateChange(isActive), + }); + }, + createAppState: state_1.createAppState, +}); +const { configDir: CONFIG_DIR, userDataPath: USER_DATA_PATH, defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, configService, anilistTokenStore, jellyfinTokenStore, anilistUpdateQueue, subtitleWsService, annotationSubtitleWsService, logger, runtimeRegistry, overlayManager, overlayModalInputState, overlayContentMeasurementStore, overlayModalRuntime, appState, appLifecycleApp, } = bootServices; +const configSettingsFields = (0, registry_2.buildConfigSettingsRegistry)(config_2.DEFAULT_CONFIG); +notifyAnilistTokenStoreWarning = (message) => { + logger.warn(`[AniList] ${message}`); + try { + (0, utils_2.showDesktopNotification)('SubMiner AniList', { + body: message, + }); + } + catch { + // Notification may fail if desktop notifications are unavailable early in startup. + } +}; +const appLogger = { + logInfo: (message) => { + logger.info(message); + }, + logDebug: (message) => { + logger.debug(message); + }, + logWarning: (message) => { + logger.warn(message); + }, + logError: (message, details) => { + logger.error(message, details); + }, + logNoRunningInstance: () => { + logger.error('No running instance. Use --start to launch the app.'); + }, + logConfigWarning: (warning) => { + logger.warn(`[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`); + }, +}; +let forceQuitTimer = null; +let statsServer = null; +const statsDaemonStatePath = path.join(USER_DATA_PATH, 'stats-daemon.json'); +function readLiveBackgroundStatsDaemonState() { + const state = (0, stats_daemon_1.readBackgroundStatsServerState)(statsDaemonStatePath); + if (!state) { + (0, stats_daemon_1.removeBackgroundStatsServerState)(statsDaemonStatePath); + return null; + } + if (state.pid === process.pid && !statsServer) { + (0, stats_daemon_1.removeBackgroundStatsServerState)(statsDaemonStatePath); + return null; + } + if (!(0, stats_daemon_1.isBackgroundStatsServerProcessAlive)(state.pid)) { + (0, stats_daemon_1.removeBackgroundStatsServerState)(statsDaemonStatePath); + return null; + } + return state; +} +function clearOwnedBackgroundStatsDaemonState() { + const state = (0, stats_daemon_1.readBackgroundStatsServerState)(statsDaemonStatePath); + if (state?.pid === process.pid) { + (0, stats_daemon_1.removeBackgroundStatsServerState)(statsDaemonStatePath); + } +} +function stopStatsServer() { + if (!statsServer) { + return; + } + statsServer.close(); + statsServer = null; + clearOwnedBackgroundStatsDaemonState(); +} +function requestAppQuit() { + (0, services_1.destroyYomitanSettingsWindow)(appState.yomitanSettingsWindow); + appState.yomitanSettingsWindow = null; + (0, stats_window_js_1.destroyStatsWindow)(); + stopStatsServer(); + if (!forceQuitTimer) { + forceQuitTimer = setTimeout(() => { + logger.warn('App quit timed out; forcing process exit.'); + electron_1.app.exit(0); + }, 2000); + } + electron_1.app.quit(); +} +process.on('SIGINT', () => { + requestAppQuit(); +}); +process.on('SIGTERM', () => { + requestAppQuit(); +}); +const startBackgroundWarmupsIfAllowed = () => { + startBackgroundWarmups(); +}; +const youtubeFlowRuntime = (0, youtube_flow_1.createYoutubeFlowRuntime)({ + probeYoutubeTracks: (url) => (0, track_probe_1.probeYoutubeTracks)(url), + acquireYoutubeSubtitleTrack: (input) => (0, generate_1.acquireYoutubeSubtitleTrack)(input), + acquireYoutubeSubtitleTracks: (input) => (0, generate_1.acquireYoutubeSubtitleTracks)(input), + openPicker: async (payload) => { + return await (0, youtube_picker_open_1.openYoutubeTrackPicker)({ + sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) => overlayModalRuntime.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions), + waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), + logWarn: (message) => logger.warn(message), + }, payload); + }, + pauseMpv: () => { + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, ['set_property', 'pause', 'yes']); + }, + resumeMpv: () => { + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, ['set_property', 'pause', 'no']); + }, + sendMpvCommand: (command) => { + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, command); + }, + requestMpvProperty: async (name) => { + const client = appState.mpvClient; + if (!client) + return null; + return await client.requestProperty(name); + }, + refreshCurrentSubtitle: (text) => { + subtitleProcessingController.refreshCurrentSubtitle(text); + }, + refreshSubtitleSidebarSource: async (sourcePath) => { + await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath); + }, + startTokenizationWarmups: async () => { + await startTokenizationWarmups(); + }, + waitForTokenizationReady: async () => { + await currentMediaTokenizationGate.waitUntilReady(appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null); + }, + waitForAnkiReady: async () => { + const integration = appState.ankiIntegration; + if (!integration) { + return; + } + try { + await Promise.race([ + integration.waitUntilReady(), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timed out waiting for AnkiConnect integration')), 2500); + }), + ]); + } + catch (error) { + logger.warn('Continuing YouTube playback before AnkiConnect integration reported ready:', error instanceof Error ? error.message : String(error)); + } + }, + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + waitForPlaybackWindowReady: async () => { + const deadline = Date.now() + 4000; + let stableGeometry = null; + let stableSinceMs = 0; + while (Date.now() < deadline) { + const tracker = appState.windowTracker; + const trackerGeometry = tracker?.getGeometry() ?? null; + const mediaPath = appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || ''; + const trackerFocused = tracker?.isTargetWindowFocused() ?? false; + if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) { + if (!geometryMatches(stableGeometry, trackerGeometry)) { + stableGeometry = trackerGeometry; + stableSinceMs = Date.now(); + } + else if (Date.now() - stableSinceMs >= 200) { + return; + } + } + else { + stableGeometry = null; + stableSinceMs = 0; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + logger.warn('Timed out waiting for tracked playback window focus/media readiness before opening YouTube subtitle picker.'); + }, + waitForOverlayGeometryReady: async () => { + const deadline = Date.now() + 4000; + while (Date.now() < deadline) { + const tracker = appState.windowTracker; + const trackerGeometry = tracker?.getGeometry() ?? null; + if (trackerGeometry && geometryMatches(lastOverlayWindowGeometry, trackerGeometry)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + logger.warn('Timed out waiting for overlay geometry to match tracked playback window.'); + }, + focusOverlayWindow: () => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + mainWindow.setIgnoreMouseEvents(false); + if (!mainWindow.isFocused()) { + mainWindow.focus(); + } + if (!mainWindow.webContents.isFocused()) { + mainWindow.webContents.focus(); + } + }, + showMpvOsd: (text) => showMpvOsd(text), + reportSubtitleFailure: (message) => reportYoutubeSubtitleFailure(message), + warn: (message) => logger.warn(message), + log: (message) => logger.info(message), + getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'), +}); +const prepareYoutubePlaybackInMpv = (0, youtube_playback_launch_1.createPrepareYoutubePlaybackInMpvHandler)({ + requestPath: async () => { + const client = appState.mpvClient; + if (!client) + return null; + const value = await client.requestProperty('path').catch(() => null); + return typeof value === 'string' ? value : null; + }, + requestProperty: async (name) => { + const client = appState.mpvClient; + if (!client) + return null; + return await client.requestProperty(name); + }, + sendMpvCommand: (command) => { + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, command); + }, + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +}); +const waitForYoutubeMpvConnected = (0, jellyfin_remote_connection_1.createWaitForMpvConnectedHandler)({ + getMpvClient: () => appState.mpvClient, + now: () => Date.now(), + sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), +}); +const autoplayReadyGate = (0, autoplay_ready_gate_1.createAutoplayReadyGate)({ + isAppOwnedFlowInFlight: () => youtubePrimarySubtitleNotificationRuntime.isAppOwnedFlowInFlight(), + getCurrentMediaPath: () => appState.currentMediaPath, + getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath ?? null, + getPlaybackPaused: () => appState.playbackPaused, + getMpvClient: () => appState.mpvClient, + signalPluginAutoplayReady: () => { + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']); + }, + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + logDebug: (message) => logger.debug(message), +}); +const managedLocalSubtitleSelectionRuntime = (0, local_subtitle_selection_1.createManagedLocalSubtitleSelectionRuntime)({ + getCurrentMediaPath: () => appState.currentMediaPath, + getMpvClient: () => appState.mpvClient, + getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, + getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, + sendMpvCommand: (command) => { + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, command); + }, + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + clearScheduled: (timer) => clearTimeout(timer), +}); +function resolveBundledMpvRuntimePluginEntrypoint() { + return ((0, first_run_setup_plugin_1.resolvePackagedRuntimePluginPath)({ + dirname: __dirname, + appPath: electron_1.app.getAppPath(), + resourcesPath: process.resourcesPath, + }) ?? undefined); +} +function detectWindowsInstalledMpvPlugin(mpvExecutablePath) { + return (0, first_run_setup_plugin_1.detectInstalledMpvPlugin)({ + platform: 'win32', + homeDir: os.homedir(), + appDataDir: electron_1.app.getPath('appData'), + mpvExecutablePath, + }); +} +function logInstalledMpvPluginDetected(detection) { + if (!detection.path) + return; + logger.warn(`SubMiner detected an installed mpv plugin at ${detection.path}. This mpv session will use the installed plugin. Remove it to use the bundled runtime plugin automatically. Detected plugin version: ${detection.version ?? 'unknown or legacy'}.`); +} +async function promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection) { + const response = await electron_1.dialog.showMessageBox({ + type: 'warning', + title: 'SubMiner mpv plugin detected', + message: [ + 'SubMiner detected an installed mpv plugin at:', + detection.path ?? 'unknown path', + '', + "This mpv session will use the installed plugin unless it is removed. Remove it now to use SubMiner's bundled runtime plugin automatically.", + `Detected plugin version: ${detection.version ?? 'unknown or legacy'}`, + ].join('\n'), + detail: 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash.', + buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'], + defaultId: 0, + cancelId: 2, + }); + if (response.response === 2) { + return 'cancel'; + } + if (response.response === 1) { + return 'continue'; + } + const result = await (0, first_run_setup_plugin_1.removeLegacyMpvPluginCandidates)({ + candidates: (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({ + platform: 'win32', + homeDir: os.homedir(), + appDataDir: electron_1.app.getPath('appData'), + mpvExecutablePath: mpvPath, + }), + trashItem: (candidatePath) => electron_1.shell.trashItem(candidatePath), + }); + if (result.ok) { + await electron_1.dialog.showMessageBox({ + type: 'info', + title: 'Legacy mpv plugin removed', + message: 'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.', + }); + return 'removed'; + } + await electron_1.dialog.showMessageBox({ + type: 'error', + title: 'Could not remove legacy mpv plugin', + message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.', + detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'), + }); + return 'cancel'; +} +const youtubePlaybackRuntime = (0, youtube_playback_runtime_1.createYoutubePlaybackRuntime)({ + platform: process.platform, + directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT, + mpvYtdlFormat: YOUTUBE_MPV_YTDL_FORMAT, + autoLaunchTimeoutMs: YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS, + connectTimeoutMs: YOUTUBE_MPV_CONNECT_TIMEOUT_MS, + getSocketPath: () => appState.mpvSocketPath, + getMpvConnected: () => Boolean(appState.mpvClient?.connected), + invalidatePendingAutoplayReadyFallbacks: () => autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(), + setAppOwnedFlowInFlight: (next) => { + youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(next); + }, + ensureYoutubePlaybackRuntimeReady: async () => { + await ensureYoutubePlaybackRuntimeReady(); + }, + resolveYoutubePlaybackUrl: (url, format) => (0, playback_resolve_1.resolveYoutubePlaybackUrl)(url, format), + launchWindowsMpv: (playbackUrl, args) => (0, windows_mpv_launch_1.launchWindowsMpv)([playbackUrl], (0, windows_mpv_launch_1.createWindowsMpvLaunchDeps)({ + showError: (title, content) => electron_1.dialog.showErrorBox(title, content), + }), [...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`], process.execPath, resolveBundledMpvRuntimePluginEntrypoint(), getResolvedConfig().mpv.executablePath, getResolvedConfig().mpv.launchMode, { + detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin, + notifyInstalledPluginDetected: logInstalledMpvPluginDetected, + resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection), + }), + waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), + prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), + runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request), + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + clearScheduled: (timer) => clearTimeout(timer), +}); +let firstRunSetupMessage = null; +const resolveWindowsMpvShortcutRuntimePaths = () => (0, windows_mpv_shortcuts_1.resolveWindowsMpvShortcutPaths)({ + appDataDir: electron_1.app.getPath('appData'), + desktopDir: electron_1.app.getPath('desktop'), +}); +const createCommandLineLauncherRuntimeOptions = () => ({ + platform: process.platform, + env: process.env, + homeDir: os.homedir(), + localAppData: process.env.LOCALAPPDATA, + userProfile: process.env.USERPROFILE, + cwd: process.cwd(), + resourcesPath: process.resourcesPath, + appExePath: process.execPath, +}); +(0, first_run_setup_plugin_1.syncInstalledFirstRunPluginBinaryPath)({ + platform: process.platform, + homeDir: os.homedir(), + xdgConfigHome: process.env.XDG_CONFIG_HOME, + binaryPath: process.execPath, +}); +const firstRunSetupService = (0, first_run_setup_service_1.createFirstRunSetupService)({ + platform: process.platform, + configDir: CONFIG_DIR, + getYomitanDictionaryCount: async () => { + await ensureYomitanExtensionLoaded(); + const dictionaries = await (0, services_1.getYomitanDictionaryInfo)(getYomitanParserRuntimeDeps(), { + error: (message, ...args) => logger.error(message, ...args), + info: (message, ...args) => logger.info(message, ...args), + }); + return dictionaries.length; + }, + isExternalYomitanConfigured: () => getResolvedConfig().yomitan.externalProfilePath.trim().length > 0, + detectPluginInstalled: () => { + const candidates = (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({ + platform: process.platform, + homeDir: os.homedir(), + xdgConfigHome: process.env.XDG_CONFIG_HOME, + appDataDir: electron_1.app.getPath('appData'), + mpvExecutablePath: getResolvedConfig().mpv.executablePath, + }); + if (candidates.length > 0) { + return true; + } + const installPaths = (0, setup_state_1.resolveDefaultMpvInstallPaths)(process.platform, os.homedir(), process.env.XDG_CONFIG_HOME); + return (0, first_run_setup_plugin_1.detectInstalledFirstRunPlugin)(installPaths); + }, + detectLegacyMpvPluginCandidates: () => (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({ + platform: process.platform, + homeDir: os.homedir(), + xdgConfigHome: process.env.XDG_CONFIG_HOME, + appDataDir: electron_1.app.getPath('appData'), + mpvExecutablePath: getResolvedConfig().mpv.executablePath, + }), + removeLegacyMpvPlugins: (candidates) => (0, first_run_setup_plugin_1.removeLegacyMpvPluginCandidates)({ + candidates, + trashItem: (candidatePath) => electron_1.shell.trashItem(candidatePath), + }), + detectWindowsMpvShortcuts: () => { + if (process.platform !== 'win32') { + return { + startMenuInstalled: false, + desktopInstalled: false, + }; + } + return (0, windows_mpv_shortcuts_1.detectWindowsMpvShortcuts)(resolveWindowsMpvShortcutRuntimePaths()); + }, + applyWindowsMpvShortcuts: async (preferences) => { + if (process.platform !== 'win32') { + return { + ok: true, + status: 'unknown', + message: '', + }; + } + return (0, windows_mpv_shortcuts_1.applyWindowsMpvShortcuts)({ + preferences, + paths: resolveWindowsMpvShortcutRuntimePaths(), + exePath: process.execPath, + writeShortcutLink: (shortcutPath, operation, details) => electron_1.shell.writeShortcutLink(shortcutPath, operation, details), + }); + }, + detectCommandLineLauncher: () => (0, command_line_launcher_1.detectCommandLineLauncher)(createCommandLineLauncherRuntimeOptions()), + installBun: async () => { + const snapshot = await (0, command_line_launcher_1.installBun)(createCommandLineLauncherRuntimeOptions()); + return { + ok: snapshot.status === 'ready', + message: snapshot.message ?? + (snapshot.status === 'ready' + ? 'Bun is ready. Open a new terminal.' + : 'Bun installation failed.'), + }; + }, + installCommandLineLauncher: async () => { + const snapshot = await (0, command_line_launcher_1.installLauncher)(createCommandLineLauncherRuntimeOptions()); + const ok = snapshot.status === 'ready' || snapshot.status === 'installed_bun_missing'; + return { + ok, + installPath: snapshot.installPath, + message: snapshot.message ?? + (ok + ? 'Command-line launcher installed. Open a new terminal.' + : 'Command-line launcher installation failed.'), + }; + }, + onStateChanged: (state) => { + appState.firstRunSetupCompleted = state.status === 'completed'; + if (appTray) { + ensureTray(); + } + }, +}); +const discordPresenceSessionStartedAtMs = Date.now(); +let discordPresenceMediaDurationSec = null; +const discordPresenceRuntime = (0, discord_presence_runtime_1.createDiscordPresenceRuntime)({ + getDiscordPresenceService: () => appState.discordPresenceService, + isDiscordPresenceEnabled: () => getResolvedConfig().discordPresence.enabled === true, + getMpvClient: () => appState.mpvClient, + getCurrentMediaTitle: () => appState.currentMediaTitle, + getCurrentMediaPath: () => appState.currentMediaPath, + getCurrentSubtitleText: () => appState.currentSubText, + getPlaybackPaused: () => appState.playbackPaused, + getFallbackMediaDurationSec: () => anilistMediaGuessRuntimeState.mediaDurationSec, + getSessionStartedAtMs: () => discordPresenceSessionStartedAtMs, + getMediaDurationSec: () => discordPresenceMediaDurationSec, + setMediaDurationSec: (next) => { + discordPresenceMediaDurationSec = next; + }, +}); +async function initializeDiscordPresenceService() { + if (getResolvedConfig().discordPresence.enabled !== true) { + appState.discordPresenceService = null; + return; + } + appState.discordPresenceService = (0, services_1.createDiscordPresenceService)({ + config: getResolvedConfig().discordPresence, + createClient: () => (0, discord_rpc_client_js_1.createDiscordRpcClient)(DISCORD_PRESENCE_APP_ID), + logDebug: (message, meta) => logger.debug(message, meta), + }); + await appState.discordPresenceService.start(); + discordPresenceRuntime.publishDiscordPresence(); +} +const ensureOverlayMpvSubtitlesHidden = (0, overlay_mpv_sub_visibility_1.createEnsureOverlayMpvSubtitlesHiddenHandler)({ + getMpvClient: () => appState.mpvClient, + getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility, + setSavedSubVisibility: (visible) => { + appState.overlaySavedMpvSubVisibility = visible; + }, + getRevision: () => appState.overlayMpvSubVisibilityRevision, + setRevision: (revision) => { + appState.overlayMpvSubVisibilityRevision = revision; + }, + setMpvSubVisibility: (visible) => { + (0, services_1.setMpvSubVisibilityRuntime)(appState.mpvClient, visible); + }, + logWarn: (message, error) => { + logger.warn(message, error); + }, +}); +const restoreOverlayMpvSubtitles = (0, overlay_mpv_sub_visibility_1.createRestoreOverlayMpvSubtitlesHandler)({ + getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility, + setSavedSubVisibility: (visible) => { + appState.overlaySavedMpvSubVisibility = visible; + }, + getRevision: () => appState.overlayMpvSubVisibilityRevision, + setRevision: (revision) => { + appState.overlayMpvSubVisibilityRevision = revision; + }, + isMpvConnected: () => Boolean(appState.mpvClient?.connected), + shouldKeepSuppressedFromVisibleOverlayBinding: () => shouldSuppressMpvSubtitlesForOverlay(), + setMpvSubVisibility: (visible) => { + (0, services_1.setMpvSubVisibilityRuntime)(appState.mpvClient, visible); + }, +}); +function shouldSuppressMpvSubtitlesForOverlay() { + return overlayManager.getVisibleOverlayVisible(); +} +function syncOverlayMpvSubtitleSuppression() { + if (shouldSuppressMpvSubtitlesForOverlay()) { + void ensureOverlayMpvSubtitlesHidden(); + return; + } + restoreOverlayMpvSubtitles(); +} +const buildImmersionMediaRuntimeMainDepsHandler = (0, startup_1.createBuildImmersionMediaRuntimeMainDepsHandler)({ + getResolvedConfig: () => getResolvedConfig(), + defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, + getTracker: () => appState.immersionTracker, + getMpvClient: () => appState.mpvClient, + getCurrentMediaPath: () => appState.currentMediaPath, + getCurrentMediaTitle: () => appState.currentMediaTitle, + logDebug: (message) => logger.debug(message), + logInfo: (message) => logger.info(message), +}); +const buildAnilistStateRuntimeMainDepsHandler = (0, startup_1.createBuildAnilistStateRuntimeMainDepsHandler)({ + getClientSecretState: () => appState.anilistClientSecretState, + setClientSecretState: (next) => { + appState.anilistClientSecretState = (0, state_1.transitionAnilistClientSecretState)(appState.anilistClientSecretState, next); + }, + getRetryQueueState: () => appState.anilistRetryQueueState, + setRetryQueueState: (next) => { + appState.anilistRetryQueueState = (0, state_1.transitionAnilistRetryQueueState)(appState.anilistRetryQueueState, next); + }, + getUpdateQueueSnapshot: () => anilistUpdateQueue.getSnapshot(), + clearStoredToken: () => anilistTokenStore.clearToken(), + clearCachedAccessToken: () => { + anilistCachedAccessToken = null; + }, +}); +const buildConfigDerivedRuntimeMainDepsHandler = (0, startup_1.createBuildConfigDerivedRuntimeMainDepsHandler)({ + getResolvedConfig: () => getResolvedConfig(), + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + defaultJimakuLanguagePreference: config_2.DEFAULT_CONFIG.jimaku.languagePreference, + defaultJimakuMaxEntryResults: config_2.DEFAULT_CONFIG.jimaku.maxEntryResults, + defaultJimakuApiBaseUrl: config_2.DEFAULT_CONFIG.jimaku.apiBaseUrl, +}); +const buildMainSubsyncRuntimeMainDepsHandler = (0, startup_1.createBuildMainSubsyncRuntimeMainDepsHandler)({ + getMpvClient: () => appState.mpvClient, + getResolvedConfig: () => getResolvedConfig(), + getSubsyncInProgress: () => appState.subsyncInProgress, + setSubsyncInProgress: (inProgress) => { + appState.subsyncInProgress = inProgress; + }, + showMpvOsd: (text) => showMpvOsd(text), + openManualPicker: (payload) => { + openOverlayHostedModalWithOsd((deps) => (0, subsync_open_1.openSubsyncManualModal)(deps, payload), 'Subsync overlay unavailable.', 'Failed to open subsync overlay.'); + }, +}); +const immersionMediaRuntime = (0, startup_1.createImmersionMediaRuntime)(buildImmersionMediaRuntimeMainDepsHandler()); +const anilistRateLimiter = (0, rate_limiter_1.createAnilistRateLimiter)(); +const statsCoverArtFetcher = (0, cover_art_fetcher_1.createCoverArtFetcher)(anilistRateLimiter, (0, logger_1.createLogger)('main:stats-cover-art')); +const anilistStateRuntime = (0, anilist_1.createAnilistStateRuntime)(buildAnilistStateRuntimeMainDepsHandler()); +const configDerivedRuntime = (0, startup_1.createConfigDerivedRuntime)(buildConfigDerivedRuntimeMainDepsHandler()); +const subsyncRuntime = (0, startup_1.createMainSubsyncRuntime)(buildMainSubsyncRuntimeMainDepsHandler()); +const currentMediaTokenizationGate = (0, current_media_tokenization_gate_1.createCurrentMediaTokenizationGate)(); +const startupOsdSequencer = (0, startup_osd_sequencer_1.createStartupOsdSequencer)({ + showOsd: (message) => showMpvOsd(message), +}); +const youtubePrimarySubtitleNotificationRuntime = (0, youtube_primary_subtitle_notification_1.createYoutubePrimarySubtitleNotificationRuntime)({ + getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, + notifyFailure: (message) => reportYoutubeSubtitleFailure(message), + schedule: (fn, delayMs) => setTimeout(fn, delayMs), + clearSchedule: youtube_primary_subtitle_notification_1.clearYoutubePrimarySubtitleNotificationTimer, +}); +function isYoutubePlaybackActiveNow() { + return (0, youtube_playback_1.isYoutubePlaybackActive)(appState.currentMediaPath, appState.mpvClient?.currentVideoPath ?? null); +} +function reportYoutubeSubtitleFailure(message) { + const type = getResolvedConfig().ankiConnect.behavior.notificationType; + if (type === 'osd' || type === 'both') { + showMpvOsd(message); + } + if (type === 'system' || type === 'both') { + try { + (0, utils_2.showDesktopNotification)('SubMiner', { body: message }); + } + catch { + logger.warn(`Unable to show desktop notification: ${message}`); + } + } +} +async function openYoutubeTrackPickerFromPlayback() { + if (youtubeFlowRuntime.hasActiveSession()) { + showMpvOsd('YouTube subtitle flow already in progress.'); + return; + } + const currentMediaPath = appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || ''; + if (!isYoutubePlaybackActiveNow() || !currentMediaPath) { + showMpvOsd('YouTube subtitle picker is only available during YouTube playback.'); + return; + } + await youtubeFlowRuntime.openManualPicker({ + url: currentMediaPath, + }); +} +let appTray = null; +let tokenizeSubtitleDeferred = null; +function withCurrentSubtitleTiming(payload) { + return { + ...payload, + startTime: appState.mpvClient?.currentSubStart ?? null, + endTime: appState.mpvClient?.currentSubEnd ?? null, + }; +} +function emitSubtitlePayload(payload) { + const timedPayload = withCurrentSubtitleTiming(payload); + appState.currentSubtitleData = timedPayload; + broadcastToOverlayWindows('subtitle:set', timedPayload); + subtitleWsService.broadcast(timedPayload, { + enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, + mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, + }); + annotationSubtitleWsService.broadcast(timedPayload, { + enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, + mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, + }); + subtitlePrefetchService?.resume(); +} +const buildSubtitleProcessingControllerMainDepsHandler = (0, startup_1.createBuildSubtitleProcessingControllerMainDepsHandler)({ + tokenizeSubtitle: async (text) => tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null }, + emitSubtitle: (payload) => emitSubtitlePayload(payload), + logDebug: (message) => { + logger.debug(`[subtitle-processing] ${message}`); + }, + now: () => Date.now(), +}); +const subtitleProcessingControllerMainDeps = buildSubtitleProcessingControllerMainDepsHandler(); +const subtitleProcessingController = (0, services_1.createSubtitleProcessingController)(subtitleProcessingControllerMainDeps); +let subtitlePrefetchService = null; +let subtitlePrefetchRefreshTimer = null; +let lastObservedTimePos = 0; +let cancelLinuxMpvFullscreenOverlayRefreshBurst = null; +const SEEK_THRESHOLD_SECONDS = 3; +function clearScheduledSubtitlePrefetchRefresh() { + if (subtitlePrefetchRefreshTimer) { + clearTimeout(subtitlePrefetchRefreshTimer); + subtitlePrefetchRefreshTimer = null; + } +} +function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst() { + cancelLinuxMpvFullscreenOverlayRefreshBurst?.(); + cancelLinuxMpvFullscreenOverlayRefreshBurst = null; +} +const subtitlePrefetchInitController = (0, subtitle_prefetch_init_1.createSubtitlePrefetchInitController)({ + getCurrentService: () => subtitlePrefetchService, + setCurrentService: (service) => { + subtitlePrefetchService = service; + }, + loadSubtitleSourceText, + parseSubtitleCues: (content, filename) => (0, subtitle_cue_parser_1.parseSubtitleCues)(content, filename), + createSubtitlePrefetchService: (deps) => (0, subtitle_prefetch_1.createSubtitlePrefetchService)(deps), + tokenizeSubtitle: async (text) => tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, + preCacheTokenization: (text, data) => { + subtitleProcessingController.preCacheTokenization(text, data); + }, + isCacheFull: () => subtitleProcessingController.isCacheFull(), + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), + onParsedSubtitleCuesChanged: (cues, sourceKey) => { + appState.activeParsedSubtitleCues = cues ?? []; + appState.activeParsedSubtitleSource = sourceKey; + }, +}); +const resolveActiveSubtitleSidebarSourceHandler = (0, subtitle_prefetch_runtime_1.createResolveActiveSubtitleSidebarSourceHandler)({ + getFfmpegPath: () => getResolvedConfig().subsync.ffmpeg_path.trim() || 'ffmpeg', + extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) => extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), +}); +async function refreshSubtitleSidebarFromSource(sourcePath) { + const normalizedSourcePath = (0, subtitle_prefetch_source_1.resolveSubtitleSourcePath)(sourcePath.trim()); + if (!normalizedSourcePath) { + return; + } + await subtitlePrefetchInitController.initSubtitlePrefetch(normalizedSourcePath, lastObservedTimePos, normalizedSourcePath); +} +const refreshSubtitlePrefetchFromActiveTrackHandler = (0, subtitle_prefetch_runtime_1.createRefreshSubtitlePrefetchFromActiveTrackHandler)({ + getMpvClient: () => appState.mpvClient, + getLastObservedTimePos: () => lastObservedTimePos, + subtitlePrefetchInitController, + resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), +}); +function scheduleSubtitlePrefetchRefresh(delayMs = 0) { + clearScheduledSubtitlePrefetchRefresh(); + subtitlePrefetchRefreshTimer = setTimeout(() => { + subtitlePrefetchRefreshTimer = null; + void refreshSubtitlePrefetchFromActiveTrackHandler(); + }, delayMs); +} +const subtitlePrefetchRuntime = { + cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(), + initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch, + refreshSubtitleSidebarFromSource: (sourcePath) => refreshSubtitleSidebarFromSource(sourcePath), + refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), + scheduleSubtitlePrefetchRefresh: (delayMs) => scheduleSubtitlePrefetchRefresh(delayMs), + clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(), +}; +const overlayShortcutsRuntime = (0, overlay_shortcuts_runtime_1.createOverlayShortcutsRuntimeService)((0, shortcuts_1.createBuildOverlayShortcutsRuntimeMainDepsHandler)({ + getConfiguredShortcuts: () => getConfiguredShortcuts(), + getShortcutsRegistered: () => appState.shortcutsRegistered, + setShortcutsRegistered: (registered) => { + appState.shortcutsRegistered = registered; + }, + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + isOverlayShortcutContextActive: () => { + if (process.platform !== 'win32') { + return true; + } + if (!overlayManager.getVisibleOverlayVisible()) { + return false; + } + const windowTracker = appState.windowTracker; + if (!windowTracker || !windowTracker.isTracking()) { + return false; + } + return windowTracker.isTargetWindowFocused(); + }, + showMpvOsd: (text) => showMpvOsd(text), + openRuntimeOptionsPalette: () => { + openRuntimeOptionsPalette(); + }, + openCharacterDictionary: () => { + openCharacterDictionaryOverlay(); + }, + openJimaku: () => { + openJimakuOverlay(); + }, + markAudioCard: () => markLastCardAsAudioCard(), + copySubtitleMultiple: (timeoutMs) => { + startPendingMultiCopy(timeoutMs); + }, + copySubtitle: () => { + copyCurrentSubtitle(); + }, + toggleSecondarySubMode: () => handleCycleSecondarySubMode(), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + mineSentenceCard: () => mineSentenceCard(), + mineSentenceMultiple: (timeoutMs) => { + startPendingMineSentenceMultiple(timeoutMs); + }, + cancelPendingMultiCopy: () => { + cancelPendingMultiCopy(); + }, + cancelPendingMineSentenceMultiple: () => { + cancelPendingMineSentenceMultiple(); + }, +})()); +syncOverlayShortcutsForModal = (isActive) => { + if (isActive) { + overlayShortcutsRuntime.unregisterOverlayShortcuts(); + } + else { + overlayShortcutsRuntime.syncOverlayShortcuts(); + } +}; +const buildConfigHotReloadMessageMainDepsHandler = (0, overlay_1.createBuildConfigHotReloadMessageMainDepsHandler)({ + showMpvOsd: (message) => showMpvOsd(message), + showDesktopNotification: (title, options) => (0, utils_2.showDesktopNotification)(title, options), +}); +const configHotReloadMessageMainDeps = buildConfigHotReloadMessageMainDepsHandler(); +const notifyConfigHotReloadMessage = (0, overlay_1.createConfigHotReloadMessageHandler)(configHotReloadMessageMainDeps); +const buildWatchConfigPathMainDepsHandler = (0, overlay_1.createBuildWatchConfigPathMainDepsHandler)({ + fileExists: (targetPath) => fs.existsSync(targetPath), + dirname: (targetPath) => path.dirname(targetPath), + watchPath: (targetPath, listener) => fs.watch(targetPath, listener), +}); +const watchConfigPathHandler = (0, overlay_1.createWatchConfigPathHandler)(buildWatchConfigPathMainDepsHandler()); +const buildConfigHotReloadAppliedMainDepsHandler = (0, overlay_1.createBuildConfigHotReloadAppliedMainDepsHandler)({ + setKeybindings: (keybindings) => { + appState.keybindings = keybindings; + }, + setSessionBindings: (sessionBindings, sessionBindingWarnings) => { + persistSessionBindings(sessionBindings, sessionBindingWarnings); + }, + refreshGlobalAndOverlayShortcuts: () => { + refreshGlobalAndOverlayShortcuts(); + }, + setSecondarySubMode: (mode) => { + setSecondarySubMode(mode); + }, + broadcastToOverlayWindows: (channel, payload) => { + broadcastToOverlayWindows(channel, payload); + }, + applyAnkiRuntimeConfigPatch: (patch) => { + if (appState.ankiIntegration) { + appState.ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, +}); +const applyConfigHotReloadDiff = (0, overlay_1.createConfigHotReloadAppliedHandler)(buildConfigHotReloadAppliedMainDepsHandler()); +const buildConfigHotReloadRuntimeMainDepsHandler = (0, overlay_1.createBuildConfigHotReloadRuntimeMainDepsHandler)({ + getCurrentConfig: () => getResolvedConfig(), + reloadConfigStrict: () => configService.reloadConfigStrict(), + watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange), + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timeout) => clearTimeout(timeout), + debounceMs: 250, + onHotReloadApplied: applyConfigHotReloadDiff, + onRestartRequired: (fields) => notifyConfigHotReloadMessage((0, overlay_1.buildRestartRequiredConfigMessage)(fields)), + onInvalidConfig: notifyConfigHotReloadMessage, + onValidationWarnings: (configPath, warnings) => { + (0, utils_2.showDesktopNotification)('SubMiner', { + body: (0, config_validation_1.buildConfigWarningNotificationBody)(configPath, warnings), + }); + if (process.platform === 'darwin') { + electron_1.dialog.showErrorBox('SubMiner config validation warning', (0, config_validation_1.buildConfigWarningDialogDetails)(configPath, warnings)); + } + }, +}); +const configHotReloadRuntime = (0, services_1.createConfigHotReloadRuntime)(buildConfigHotReloadRuntimeMainDepsHandler()); +function getConfigSettingsSnapshot() { + return (0, jsonc_edit_1.buildConfigSettingsSnapshot)({ + configPath: configService.getConfigPath(), + rawConfig: configService.getRawConfig(), + resolvedConfig: configService.getConfig(), + warnings: configService.getWarnings(), + fields: configSettingsFields, + }); +} +function isConfigSettingsPatch(value) { + if (!value || typeof value !== 'object') { + return false; + } + const operations = value.operations; + return (Array.isArray(operations) && + operations.every((operation) => { + if (!operation || typeof operation !== 'object') { + return false; + } + const candidate = operation; + return ((candidate.op === 'set' || candidate.op === 'reset') && + typeof candidate.path === 'string' && + configSettingsFields.some((field) => field.configPath === candidate.path)); + })); +} +function writeTextFileAtomically(targetPath, content) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const tempPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`); + try { + fs.writeFileSync(tempPath, content, 'utf-8'); + fs.renameSync(tempPath, targetPath); + } + catch (error) { + try { + fs.rmSync(tempPath, { force: true }); + } + catch { + // Best effort cleanup after a failed atomic write. + } + throw error; + } +} +function getRestartRequiredSettingsSections(restartRequiredFields) { + const sections = new Set(); + for (const field of configSettingsFields) { + if (restartRequiredFields.some((restartField) => field.configPath === restartField || + field.configPath.startsWith(`${restartField}.`) || + restartField.startsWith(`${field.configPath}.`))) { + sections.add(field.section); + } + } + return [...sections].sort(); +} +const saveConfigSettingsPatch = (0, config_settings_save_1.createSaveConfigSettingsPatchHandler)({ + getConfigPath: () => configService.getConfigPath(), + getCurrentConfig: () => configService.getConfig(), + getWarnings: () => configService.getWarnings(), + getSnapshot: () => getConfigSettingsSnapshot(), + fileExists: (targetPath) => fs.existsSync(targetPath), + readText: (targetPath) => fs.readFileSync(targetPath, 'utf-8'), + writeTextAtomically: (targetPath, content) => writeTextFileAtomically(targetPath, content), + reloadConfigStrict: () => configService.reloadConfigStrict(), + classifyDiff: (previous, next) => (0, services_1.classifyConfigHotReloadDiff)(previous, next), + applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config), + getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(fields), +}); +function ensureConfigSettingsFileExists() { + const configPath = configService.getConfigPath(); + if (!fs.existsSync(configPath)) { + writeTextFileAtomically(configPath, '{}\n'); + } + return configPath; +} +const openConfigSettingsWindow = (0, config_settings_window_1.createOpenConfigSettingsWindowHandler)({ + getSettingsWindow: () => appState.configSettingsWindow, + setSettingsWindow: (window) => { + appState.configSettingsWindow = window; + }, + createSettingsWindow: (0, setup_window_factory_1.createCreateConfigSettingsWindowHandler)({ + createBrowserWindow: (options) => new electron_1.BrowserWindow(options), + preloadPath: path.join(__dirname, 'preload-settings.js'), + }), + settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'), +}); +electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.getConfigSettingsSnapshot, () => getConfigSettingsSnapshot()); +electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.saveConfigSettingsPatch, (_event, patch) => { + if (!isConfigSettingsPatch(patch)) { + return { + ok: false, + warnings: [], + error: 'Invalid config settings patch.', + hotReloadFields: [], + restartRequiredFields: [], + restartRequiredSections: [], + }; + } + return saveConfigSettingsPatch(patch); +}); +electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.openConfigSettingsFile, async () => { + const openError = await electron_1.shell.openPath(ensureConfigSettingsFileExists()); + return openError.length === 0; +}); +electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.openConfigSettingsWindow, () => openConfigSettingsWindow()); +const buildDictionaryRootsHandler = (0, startup_1.createBuildDictionaryRootsMainHandler)({ + platform: process.platform, + dirname: __dirname, + appPath: electron_1.app.getAppPath(), + resourcesPath: process.resourcesPath, + userDataPath: USER_DATA_PATH, + appUserDataPath: electron_1.app.getPath('userData'), + homeDir: os.homedir(), + appDataDir: process.env.APPDATA, + cwd: process.cwd(), + joinPath: (...parts) => path.join(...parts), +}); +const buildFrequencyDictionaryRootsHandler = (0, startup_1.createBuildFrequencyDictionaryRootsMainHandler)({ + platform: process.platform, + dirname: __dirname, + appPath: electron_1.app.getAppPath(), + resourcesPath: process.resourcesPath, + userDataPath: USER_DATA_PATH, + appUserDataPath: electron_1.app.getPath('userData'), + homeDir: os.homedir(), + appDataDir: process.env.APPDATA, + cwd: process.cwd(), + joinPath: (...parts) => path.join(...parts), +}); +const jlptDictionaryRuntime = (0, jlpt_runtime_1.createJlptDictionaryRuntimeService)((0, startup_1.createBuildJlptDictionaryRuntimeMainDepsHandler)({ + isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, + getDictionaryRoots: () => buildDictionaryRootsHandler(), + getJlptDictionarySearchPaths: jlpt_runtime_1.getJlptDictionarySearchPaths, + setJlptLevelLookup: (lookup) => { + appState.jlptLevelLookup = lookup; + }, + logInfo: (message) => logger.info(message), +})()); +const frequencyDictionaryRuntime = (0, frequency_dictionary_runtime_1.createFrequencyDictionaryRuntimeService)((0, startup_1.createBuildFrequencyDictionaryRuntimeMainDepsHandler)({ + isFrequencyDictionaryEnabled: () => getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + getDictionaryRoots: () => buildFrequencyDictionaryRootsHandler(), + getFrequencyDictionarySearchPaths: frequency_dictionary_runtime_1.getFrequencyDictionarySearchPaths, + getSourcePath: () => getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath, + setFrequencyRankLookup: (lookup) => { + appState.frequencyRankLookup = lookup; + }, + logInfo: (message) => logger.info(message), +})()); +const buildGetFieldGroupingResolverMainDepsHandler = (0, overlay_1.createBuildGetFieldGroupingResolverMainDepsHandler)({ + getResolver: () => appState.fieldGroupingResolver, +}); +const getFieldGroupingResolverMainDeps = buildGetFieldGroupingResolverMainDepsHandler(); +const getFieldGroupingResolverHandler = (0, overlay_1.createGetFieldGroupingResolverHandler)(getFieldGroupingResolverMainDeps); +function getFieldGroupingResolver() { + return getFieldGroupingResolverHandler(); +} +const buildSetFieldGroupingResolverMainDepsHandler = (0, overlay_1.createBuildSetFieldGroupingResolverMainDepsHandler)({ + setResolver: (resolver) => { + appState.fieldGroupingResolver = resolver; + }, + nextSequence: () => { + appState.fieldGroupingResolverSequence += 1; + return appState.fieldGroupingResolverSequence; + }, + getSequence: () => appState.fieldGroupingResolverSequence, +}); +const setFieldGroupingResolverMainDeps = buildSetFieldGroupingResolverMainDepsHandler(); +const setFieldGroupingResolverHandler = (0, overlay_1.createSetFieldGroupingResolverHandler)(setFieldGroupingResolverMainDeps); +function setFieldGroupingResolver(resolver) { + setFieldGroupingResolverHandler(resolver); +} +const fieldGroupingOverlayRuntime = (0, services_1.createFieldGroupingOverlayRuntime)((0, overlay_1.createBuildFieldGroupingOverlayMainDepsHandler)({ + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + getResolver: () => getFieldGroupingResolver(), + setResolver: (resolver) => setFieldGroupingResolver(resolver), + getRestoreVisibleOverlayOnModalClose: () => overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), +})()); +const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback; +const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions'); +const mediaRuntime = (0, media_runtime_1.createMediaRuntimeService)((0, startup_1.createBuildMediaRuntimeMainDepsHandler)({ + isRemoteMediaPath: (mediaPath) => (0, utils_1.isRemoteMediaPath)(mediaPath), + loadSubtitlePosition: () => loadSubtitlePosition(), + getCurrentMediaPath: () => appState.currentMediaPath, + getPendingSubtitlePosition: () => appState.pendingSubtitlePosition, + getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR, + setCurrentMediaPath: (nextPath) => { + appState.currentMediaPath = nextPath; + }, + clearPendingSubtitlePosition: () => { + appState.pendingSubtitlePosition = null; + }, + setSubtitlePosition: (position) => { + appState.subtitlePosition = position; + }, + broadcastToOverlayWindows: (channel, payload) => { + broadcastToOverlayWindows(channel, payload); + }, + getCurrentMediaTitle: () => appState.currentMediaTitle, + setCurrentMediaTitle: (title) => { + appState.currentMediaTitle = title; + }, +})()); +const characterDictionaryRuntime = (0, character_dictionary_runtime_1.createCharacterDictionaryRuntimeService)({ + userDataPath: USER_DATA_PATH, + getCurrentMediaPath: () => appState.currentMediaPath, + getCurrentMediaTitle: () => appState.currentMediaTitle, + resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath), + guessAnilistMediaInfo: (mediaPath, mediaTitle) => (0, anilist_updater_1.guessAnilistMediaInfo)(mediaPath, mediaTitle), + getCollapsibleSectionOpenState: (section) => getResolvedConfig().anilist.characterDictionary.collapsibleSections[section], + now: () => Date.now(), + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), +}); +const characterDictionaryAutoSyncRuntime = (0, character_dictionary_auto_sync_1.createCharacterDictionaryAutoSyncRuntimeService)({ + userDataPath: USER_DATA_PATH, + getConfig: () => { + const config = getResolvedConfig().anilist.characterDictionary; + return { + enabled: config.enabled && + yomitanProfilePolicy.isCharacterDictionaryEnabled() && + !isYoutubePlaybackActiveNow(), + maxLoaded: config.maxLoaded, + profileScope: config.profileScope, + }; + }, + getOrCreateCurrentSnapshot: (targetPath, progress) => characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress), + buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds), + waitForYomitanMutationReady: () => currentMediaTokenizationGate.waitUntilReady(appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null), + getYomitanDictionaryInfo: async () => { + await ensureYomitanExtensionLoaded(); + return await (0, services_1.getYomitanDictionaryInfo)(getYomitanParserRuntimeDeps(), { + error: (message, ...args) => logger.error(message, ...args), + info: (message, ...args) => logger.info(message, ...args), + }); + }, + importYomitanDictionary: async (zipPath) => { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + yomitanProfilePolicy.logSkippedWrite((0, yomitan_read_only_log_1.formatSkippedYomitanWriteAction)('importYomitanDictionary', zipPath)); + return false; + } + await ensureYomitanExtensionLoaded(); + return await (0, services_1.importYomitanDictionaryFromZip)(zipPath, getYomitanParserRuntimeDeps(), { + error: (message, ...args) => logger.error(message, ...args), + info: (message, ...args) => logger.info(message, ...args), + }); + }, + deleteYomitanDictionary: async (dictionaryTitle) => { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + yomitanProfilePolicy.logSkippedWrite((0, yomitan_read_only_log_1.formatSkippedYomitanWriteAction)('deleteYomitanDictionary', dictionaryTitle)); + return false; + } + await ensureYomitanExtensionLoaded(); + return await (0, services_1.deleteYomitanDictionaryByTitle)(dictionaryTitle, getYomitanParserRuntimeDeps(), { + error: (message, ...args) => logger.error(message, ...args), + info: (message, ...args) => logger.info(message, ...args), + }); + }, + upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + yomitanProfilePolicy.logSkippedWrite((0, yomitan_read_only_log_1.formatSkippedYomitanWriteAction)('upsertYomitanDictionarySettings', dictionaryTitle)); + return false; + } + await ensureYomitanExtensionLoaded(); + return await (0, services_1.upsertYomitanDictionarySettings)(dictionaryTitle, profileScope, getYomitanParserRuntimeDeps(), { + error: (message, ...args) => logger.error(message, ...args), + info: (message, ...args) => logger.info(message, ...args), + }); + }, + now: () => Date.now(), + schedule: (fn, delayMs) => setTimeout(fn, delayMs), + clearSchedule: (timer) => clearTimeout(timer), + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), + onSyncStatus: (event) => { + (0, character_dictionary_auto_sync_notifications_1.notifyCharacterDictionaryAutoSyncStatus)(event, { + getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType, + showOsd: (message) => showMpvOsd(message), + showDesktopNotification: (title, options) => (0, utils_2.showDesktopNotification)(title, options), + startupOsdSequencer, + }); + }, + onSyncComplete: ({ mediaId, mediaTitle, changed }) => { + (0, character_dictionary_auto_sync_completion_1.handleCharacterDictionaryAutoSyncComplete)({ + mediaId, + mediaTitle, + changed, + }, { + hasParserWindow: () => Boolean(appState.yomitanParserWindow), + clearParserCaches: () => { + if (appState.yomitanParserWindow) { + (0, services_1.clearYomitanParserCachesForWindow)(appState.yomitanParserWindow); + } + }, + invalidateTokenizationCache: () => { + subtitleProcessingController.invalidateTokenizationCache(); + }, + refreshSubtitlePrefetch: () => { + subtitlePrefetchService?.onSeek(lastObservedTimePos); + }, + refreshCurrentSubtitle: () => { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + }, + logInfo: (message) => logger.info(message), + }); + }, +}); +const overlayVisibilityRuntime = (0, overlay_visibility_runtime_1.createOverlayVisibilityRuntimeService)((0, overlay_1.createBuildOverlayVisibilityRuntimeMainDepsHandler)({ + getMainWindow: () => overlayManager.getMainWindow(), + getModalActive: () => overlayModalInputState.getModalInputExclusive(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getForceMousePassthrough: () => appState.statsOverlayVisible, + getOverlayInteractionActive: () => visibleOverlayInteractionActive, + getWindowTracker: () => appState.windowTracker, + getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, + getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), + getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(), + getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, + setTrackerNotReadyWarningShown: (shown) => { + appState.trackerNotReadyWarningShown = shown; + }, + updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window) => { + ensureOverlayWindowLevel(window); + }, + syncWindowsOverlayToMpvZOrder: (_window) => { + requestWindowsVisibleOverlayZOrderSync(); + }, + syncPrimaryOverlayWindowLayer: (layer) => { + syncPrimaryOverlayWindowLayer(layer); + }, + enforceOverlayLayerOrder: () => { + enforceOverlayLayerOrder(); + }, + syncOverlayShortcuts: () => { + overlayShortcutsRuntime.syncOverlayShortcuts(); + }, + isMacOSPlatform: () => process.platform === 'darwin', + isWindowsPlatform: () => process.platform === 'win32', + showOverlayLoadingOsd: (message) => { + showMpvOsd(message); + }, + resolveFallbackBounds: () => { + const cursorPoint = electron_1.screen.getCursorScreenPoint(); + const display = electron_1.screen.getDisplayNearestPoint(cursorPoint); + const fallbackBounds = display.workArea; + return { + x: fallbackBounds.x, + y: fallbackBounds.y, + width: fallbackBounds.width, + height: fallbackBounds.height, + }; + }, +})()); +const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250]; +const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480]; +const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; +const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; +let visibleOverlayBlurRefreshTimeouts = []; +let windowsVisibleOverlayZOrderRetryTimeouts = []; +let windowsVisibleOverlayZOrderSyncInFlight = false; +let windowsVisibleOverlayZOrderSyncQueued = false; +let windowsVisibleOverlayForegroundPollInterval = null; +let lastWindowsVisibleOverlayForegroundProcessName = null; +let lastWindowsVisibleOverlayBlurredAtMs = 0; +let visibleOverlayInteractionActive = false; +function clearVisibleOverlayBlurRefreshTimeouts() { + for (const timeout of visibleOverlayBlurRefreshTimeouts) { + clearTimeout(timeout); + } + visibleOverlayBlurRefreshTimeouts = []; +} +function clearWindowsVisibleOverlayZOrderRetryTimeouts() { + for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) { + clearTimeout(timeout); + } + windowsVisibleOverlayZOrderRetryTimeouts = []; +} +function getWindowsNativeWindowHandle(window) { + const handle = window.getNativeWindowHandle(); + return handle.length >= 8 + ? handle.readBigUInt64LE(0).toString() + : BigInt(handle.readUInt32LE(0)).toString(); +} +function getWindowsNativeWindowHandleNumber(window) { + const handle = window.getNativeWindowHandle(); + return handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0); +} +function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath) { + if (process.platform !== 'win32') { + return null; + } + try { + if (targetMpvSocketPath) { + const windowTracker = appState.windowTracker; + const trackedHandle = windowTracker?.getTargetWindowHandle?.(); + if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) { + return trackedHandle; + } + } + return (0, windows_helper_1.findWindowsMpvTargetWindowHandle)(); + } + catch { + return null; + } +} +async function syncWindowsVisibleOverlayToMpvZOrder() { + if (process.platform !== 'win32') { + return false; + } + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || + mainWindow.isDestroyed() || + !mainWindow.isVisible() || + !overlayManager.getVisibleOverlayVisible()) { + return false; + } + const windowTracker = appState.windowTracker; + if (!windowTracker) { + return false; + } + if (typeof windowTracker.isTargetWindowMinimized === 'function' && + windowTracker.isTargetWindowMinimized()) { + return false; + } + if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) { + return false; + } + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath); + if (targetWindowHwnd !== null && (0, windows_helper_1.bindWindowsOverlayAboveMpv)(overlayHwnd, targetWindowHwnd)) { + mainWindow.setOpacity?.(1); + return true; + } + return false; +} +function requestWindowsVisibleOverlayZOrderSync() { + if (process.platform !== 'win32') { + return; + } + if (windowsVisibleOverlayZOrderSyncInFlight) { + windowsVisibleOverlayZOrderSyncQueued = true; + return; + } + windowsVisibleOverlayZOrderSyncInFlight = true; + void syncWindowsVisibleOverlayToMpvZOrder() + .catch((error) => { + logger.warn('Failed to bind Windows overlay z-order to mpv', error); + }) + .finally(() => { + windowsVisibleOverlayZOrderSyncInFlight = false; + if (!windowsVisibleOverlayZOrderSyncQueued) { + return; + } + windowsVisibleOverlayZOrderSyncQueued = false; + requestWindowsVisibleOverlayZOrderSync(); + }); +} +function scheduleWindowsVisibleOverlayZOrderSyncBurst() { + if (process.platform !== 'win32') { + return; + } + clearWindowsVisibleOverlayZOrderRetryTimeouts(); + for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) { + const retryTimeout = setTimeout(() => { + windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter((timeout) => timeout !== retryTimeout); + requestWindowsVisibleOverlayZOrderSync(); + }, delayMs); + windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout); + } +} +function hasWindowsVisibleOverlayFocusHandoffGrace() { + return (process.platform === 'win32' && + lastWindowsVisibleOverlayBlurredAtMs > 0 && + Date.now() - lastWindowsVisibleOverlayBlurredAtMs <= + WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS); +} +function shouldPollWindowsVisibleOverlayForegroundProcess() { + if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) { + return false; + } + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return false; + } + const windowTracker = appState.windowTracker; + if (!windowTracker) { + return false; + } + if (typeof windowTracker.isTargetWindowMinimized === 'function' && + windowTracker.isTargetWindowMinimized()) { + return false; + } + const overlayFocused = mainWindow.isFocused(); + const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false; + return !overlayFocused && !trackerFocused; +} +function maybePollWindowsVisibleOverlayForegroundProcess() { + if (!shouldPollWindowsVisibleOverlayForegroundProcess()) { + lastWindowsVisibleOverlayForegroundProcessName = null; + return; + } + const processName = (0, windows_helper_1.getWindowsForegroundProcessName)(); + const normalizedProcessName = processName?.trim().toLowerCase() ?? null; + const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName; + lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName; + if (normalizedProcessName !== previousProcessName) { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + } + if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') { + requestWindowsVisibleOverlayZOrderSync(); + } +} +function ensureWindowsVisibleOverlayForegroundPollLoop() { + if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) { + return; + } + windowsVisibleOverlayForegroundPollInterval = setInterval(() => { + maybePollWindowsVisibleOverlayForegroundProcess(); + }, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS); +} +function clearWindowsVisibleOverlayForegroundPollLoop() { + if (windowsVisibleOverlayForegroundPollInterval === null) { + return; + } + clearInterval(windowsVisibleOverlayForegroundPollInterval); + windowsVisibleOverlayForegroundPollInterval = null; +} +function scheduleVisibleOverlayBlurRefresh() { + if (process.platform !== 'win32' && process.platform !== 'darwin') { + return; + } + if (process.platform === 'win32') { + lastWindowsVisibleOverlayBlurredAtMs = Date.now(); + } + clearVisibleOverlayBlurRefreshTimeouts(); + for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { + const refreshTimeout = setTimeout(() => { + visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter((timeout) => timeout !== refreshTimeout); + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, delayMs); + visibleOverlayBlurRefreshTimeouts.push(refreshTimeout); + } +} +ensureWindowsVisibleOverlayForegroundPollLoop(); +const buildGetRuntimeOptionsStateMainDepsHandler = (0, overlay_1.createBuildGetRuntimeOptionsStateMainDepsHandler)({ + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, +}); +const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler(); +const getRuntimeOptionsStateHandler = (0, overlay_1.createGetRuntimeOptionsStateHandler)(getRuntimeOptionsStateMainDeps); +function getRuntimeOptionsState() { + return getRuntimeOptionsStateHandler(); +} +function getOverlayWindows() { + return overlayManager.getOverlayWindows(); +} +const buildRestorePreviousSecondarySubVisibilityMainDepsHandler = (0, overlay_1.createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler)({ + getMpvClient: () => appState.mpvClient, +}); +syncOverlayVisibilityForModal = () => { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); +}; +function broadcastToOverlayWindows(channel, ...args) { + overlayManager.broadcastToOverlayWindows(channel, ...args); +} +const buildBroadcastRuntimeOptionsChangedMainDepsHandler = (0, overlay_1.createBuildBroadcastRuntimeOptionsChangedMainDepsHandler)({ + broadcastRuntimeOptionsChangedRuntime: services_1.broadcastRuntimeOptionsChangedRuntime, + getRuntimeOptionsState: () => getRuntimeOptionsState(), + broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args), +}); +const buildSendToActiveOverlayWindowMainDepsHandler = (0, overlay_1.createBuildSendToActiveOverlayWindowMainDepsHandler)({ + sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) => overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), +}); +const buildSetOverlayDebugVisualizationEnabledMainDepsHandler = (0, overlay_1.createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler)({ + setOverlayDebugVisualizationEnabledRuntime: services_1.setOverlayDebugVisualizationEnabledRuntime, + getCurrentEnabled: () => appState.overlayDebugVisualizationEnabled, + setCurrentEnabled: (next) => { + appState.overlayDebugVisualizationEnabled = next; + }, +}); +const buildOpenRuntimeOptionsPaletteMainDepsHandler = (0, overlay_1.createBuildOpenRuntimeOptionsPaletteMainDepsHandler)({ + openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(), +}); +const overlayVisibilityComposer = (0, composers_1.composeOverlayVisibilityRuntime)({ + overlayVisibilityRuntime, + restorePreviousSecondarySubVisibilityMainDeps: buildRestorePreviousSecondarySubVisibilityMainDepsHandler(), + broadcastRuntimeOptionsChangedMainDeps: buildBroadcastRuntimeOptionsChangedMainDepsHandler(), + sendToActiveOverlayWindowMainDeps: buildSendToActiveOverlayWindowMainDepsHandler(), + setOverlayDebugVisualizationEnabledMainDeps: buildSetOverlayDebugVisualizationEnabledMainDepsHandler(), + openRuntimeOptionsPaletteMainDeps: buildOpenRuntimeOptionsPaletteMainDepsHandler(), +}); +function restorePreviousSecondarySubVisibility() { + overlayVisibilityComposer.restorePreviousSecondarySubVisibility(); +} +function broadcastRuntimeOptionsChanged() { + overlayVisibilityComposer.broadcastRuntimeOptionsChanged(); +} +function sendToActiveOverlayWindow(channel, payload, runtimeOptions) { + return overlayVisibilityComposer.sendToActiveOverlayWindow(channel, payload, runtimeOptions); +} +function setOverlayDebugVisualizationEnabled(enabled) { + overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled); +} +function createOverlayHostedModalOpenDeps() { + return { + ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + ensureOverlayWindowsReadyForVisibilityActions: () => ensureOverlayWindowsReadyForVisibilityActions(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => sendToActiveOverlayWindow(channel, payload, runtimeOptions), + waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), + logWarn: (message) => logger.warn(message), + }; +} +function openOverlayHostedModalWithOsd(openModal, unavailableMessage, failureLogMessage) { + void openModal(createOverlayHostedModalOpenDeps()) + .then((opened) => { + if (!opened) { + showMpvOsd(unavailableMessage); + } + }) + .catch((error) => { + logger.error(failureLogMessage, error); + showMpvOsd(unavailableMessage); + }); +} +function openRuntimeOptionsPalette() { + openOverlayHostedModalWithOsd(runtime_options_open_1.openRuntimeOptionsModal, 'Runtime options overlay unavailable.', 'Failed to open runtime options overlay.'); +} +function openJimakuOverlay() { + openOverlayHostedModalWithOsd(jimaku_open_1.openJimakuModal, 'Jimaku overlay unavailable.', 'Failed to open Jimaku overlay.'); +} +function openSessionHelpOverlay() { + openOverlayHostedModalWithOsd(session_help_open_1.openSessionHelpModal, 'Session help overlay unavailable.', 'Failed to open session help overlay.'); +} +function openCharacterDictionaryOverlay() { + openOverlayHostedModalWithOsd(character_dictionary_open_1.openCharacterDictionaryModal, 'Character dictionary overlay unavailable.', 'Failed to open character dictionary overlay.'); +} +function openControllerSelectOverlay() { + openOverlayHostedModalWithOsd(controller_select_open_1.openControllerSelectModal, 'Controller select overlay unavailable.', 'Failed to open controller select overlay.'); +} +function openControllerDebugOverlay() { + openOverlayHostedModalWithOsd(controller_debug_open_1.openControllerDebugModal, 'Controller debug overlay unavailable.', 'Failed to open controller debug overlay.'); +} +function openPlaylistBrowser() { + if (!appState.mpvClient?.connected) { + showMpvOsd('Playlist browser requires active playback.'); + return; + } + openOverlayHostedModalWithOsd(playlist_browser_open_1.openPlaylistBrowser, 'Playlist browser overlay unavailable.', 'Failed to open playlist browser overlay.'); +} +function getResolvedConfig() { + return configService.getConfig(); +} +function getRuntimeBooleanOption(id, fallback) { + const value = appState.runtimeOptionsManager?.getOptionValue(id); + return typeof value === 'boolean' ? value : fallback; +} +function shouldInitializeMecabForAnnotations() { + const config = getResolvedConfig(); + const nPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.knownWords.highlightEnabled); + const jlptEnabled = getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt); + const frequencyEnabled = getRuntimeBooleanOption('subtitle.annotation.frequency', config.subtitleStyle.frequencyDictionary.enabled); + return nPlusOneEnabled || jlptEnabled || frequencyEnabled; +} +const { getResolvedJellyfinConfig, reportJellyfinRemoteProgress, reportJellyfinRemoteStopped, startJellyfinRemoteSession, stopJellyfinRemoteSession, runJellyfinCommand, openJellyfinSetupWindow, getJellyfinClientInfo, } = (0, composers_1.composeJellyfinRuntimeHandlers)({ + getResolvedJellyfinConfigMainDeps: { + getResolvedConfig: () => getResolvedConfig(), + loadStoredSession: () => jellyfinTokenStore.loadSession(), + getEnv: (name) => process.env[name], + }, + getJellyfinClientInfoMainDeps: { + getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), + getDefaultJellyfinConfig: () => config_2.DEFAULT_CONFIG.jellyfin, + }, + waitForMpvConnectedMainDeps: { + getMpvClient: () => appState.mpvClient, + now: () => Date.now(), + sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), + }, + launchMpvIdleForJellyfinPlaybackMainDeps: { + getSocketPath: () => appState.mpvSocketPath, + getLaunchMode: () => getResolvedConfig().mpv.launchMode, + platform: process.platform, + execPath: process.execPath, + defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, + defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, + removeSocketPath: (socketPath) => { + fs.rmSync(socketPath, { force: true }); + }, + spawnMpv: (args) => (0, node_child_process_1.spawn)('mpv', args, { + detached: true, + stdio: 'ignore', + }), + logWarn: (message, error) => logger.warn(message, error), + logInfo: (message) => logger.info(message), + }, + ensureMpvConnectedForJellyfinPlaybackMainDeps: { + getMpvClient: () => appState.mpvClient, + setMpvClient: (client) => { + appState.mpvClient = client; + }, + createMpvClient: () => createMpvClientRuntimeService(), + getAutoLaunchInFlight: () => jellyfinMpvAutoLaunchInFlight, + setAutoLaunchInFlight: (promise) => { + jellyfinMpvAutoLaunchInFlight = promise; + }, + connectTimeoutMs: JELLYFIN_MPV_CONNECT_TIMEOUT_MS, + autoLaunchTimeoutMs: JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS, + }, + preloadJellyfinExternalSubtitlesMainDeps: { + listJellyfinSubtitleTracks: (session, clientInfo, itemId) => (0, services_1.listJellyfinSubtitleTracksRuntime)(session, clientInfo, itemId), + getMpvClient: () => appState.mpvClient, + sendMpvCommand: (command) => { + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, command); + }, + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + logDebug: (message, error) => { + logger.debug(message, error); + }, + }, + playJellyfinItemInMpvMainDeps: { + getMpvClient: () => appState.mpvClient, + resolvePlaybackPlan: (params) => (0, services_1.resolveJellyfinPlaybackPlanRuntime)(params.session, params.clientInfo, params.jellyfinConfig, { + itemId: params.itemId, + audioStreamIndex: params.audioStreamIndex ?? undefined, + subtitleStreamIndex: params.subtitleStreamIndex ?? undefined, + }), + applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient), + sendMpvCommand: (command) => (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, command), + armQuitOnDisconnect: () => { + jellyfinPlayQuitOnDisconnectArmed = false; + setTimeout(() => { + jellyfinPlayQuitOnDisconnectArmed = true; + }, 3000); + }, + schedule: (callback, delayMs) => { + setTimeout(callback, delayMs); + }, + convertTicksToSeconds: (ticks) => (0, services_1.jellyfinTicksToSecondsRuntime)(ticks), + setActivePlayback: (state) => { + activeJellyfinRemotePlayback = state; + }, + setLastProgressAtMs: (value) => { + jellyfinRemoteLastProgressAtMs = value; + }, + reportPlaying: (payload) => { + void appState.jellyfinRemoteSession?.reportPlaying(payload); + }, + showMpvOsd: (text) => { + showMpvOsd(text); + }, + }, + remoteComposerOptions: { + getConfiguredSession: () => (0, jellyfin_1.getConfiguredJellyfinSession)(getResolvedJellyfinConfig()), + logWarn: (message) => logger.warn(message), + getMpvClient: () => appState.mpvClient, + sendMpvCommand: (client, command) => (0, services_1.sendMpvCommandRuntime)(client, command), + jellyfinTicksToSeconds: (ticks) => (0, services_1.jellyfinTicksToSecondsRuntime)(ticks), + getActivePlayback: () => activeJellyfinRemotePlayback, + clearActivePlayback: () => { + activeJellyfinRemotePlayback = null; + }, + getSession: () => appState.jellyfinRemoteSession, + getNow: () => Date.now(), + getLastProgressAtMs: () => jellyfinRemoteLastProgressAtMs, + setLastProgressAtMs: (value) => { + jellyfinRemoteLastProgressAtMs = value; + }, + progressIntervalMs: JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS, + ticksPerSecond: JELLYFIN_TICKS_PER_SECOND, + logDebug: (message, error) => logger.debug(message, error), + }, + handleJellyfinAuthCommandsMainDeps: { + patchRawConfig: (patch) => { + configService.patchRawConfig(patch); + refreshTrayMenuIfPresent(); + }, + authenticateWithPassword: (serverUrl, username, password, clientInfo) => (0, services_1.authenticateWithPasswordRuntime)(serverUrl, username, password, clientInfo), + saveStoredSession: (session) => jellyfinTokenStore.saveSession(session), + clearStoredSession: () => (0, jellyfin_tray_discovery_1.clearJellyfinAuthSessionAndRefreshTray)(getJellyfinTrayDiscoveryDeps()), + logInfo: (message) => logger.info(message), + }, + handleJellyfinListCommandsMainDeps: { + listJellyfinLibraries: (session, clientInfo) => (0, services_1.listJellyfinLibrariesRuntime)(session, clientInfo), + listJellyfinItems: (session, clientInfo, params) => (0, services_1.listJellyfinItemsRuntime)(session, clientInfo, params), + listJellyfinSubtitleTracks: (session, clientInfo, itemId) => (0, services_1.listJellyfinSubtitleTracksRuntime)(session, clientInfo, itemId), + writeJellyfinPreviewAuth: (responsePath, payload) => { + fs.mkdirSync(path.dirname(responsePath), { recursive: true }); + fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8'); + }, + logInfo: (message) => logger.info(message), + }, + handleJellyfinPlayCommandMainDeps: { + logWarn: (message) => logger.warn(message), + }, + handleJellyfinRemoteAnnounceCommandMainDeps: { + getRemoteSession: () => appState.jellyfinRemoteSession, + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), + }, + startJellyfinRemoteSessionMainDeps: { + getCurrentSession: () => appState.jellyfinRemoteSession, + setCurrentSession: (session) => { + appState.jellyfinRemoteSession = session; + }, + createRemoteSessionService: (options) => new services_1.JellyfinRemoteSessionService(options), + defaultDeviceId: config_2.DEFAULT_CONFIG.jellyfin.deviceId, + defaultClientName: config_2.DEFAULT_CONFIG.jellyfin.clientName, + defaultClientVersion: config_2.DEFAULT_CONFIG.jellyfin.clientVersion, + logInfo: (message) => logger.info(message), + logWarn: (message, details) => logger.warn(message, details), + }, + stopJellyfinRemoteSessionMainDeps: { + getCurrentSession: () => appState.jellyfinRemoteSession, + setCurrentSession: (session) => { + appState.jellyfinRemoteSession = session; + }, + clearActivePlayback: () => { + activeJellyfinRemotePlayback = null; + }, + }, + runJellyfinCommandMainDeps: { + defaultServerUrl: config_2.DEFAULT_CONFIG.jellyfin.serverUrl, + }, + maybeFocusExistingJellyfinSetupWindowMainDeps: { + getSetupWindow: () => appState.jellyfinSetupWindow, + }, + openJellyfinSetupWindowMainDeps: { + createSetupWindow: (0, setup_window_factory_1.createCreateJellyfinSetupWindowHandler)({ + createBrowserWindow: (options) => new electron_1.BrowserWindow(options), + }), + buildSetupFormHtml: (state) => (0, jellyfin_1.buildJellyfinSetupFormHtml)(state), + parseSubmissionUrl: (rawUrl) => (0, jellyfin_1.parseJellyfinSetupSubmissionUrl)(rawUrl), + authenticateWithPassword: (server, username, password, clientInfo) => (0, services_1.authenticateWithPasswordRuntime)(server, username, password, clientInfo), + saveStoredSession: (session) => jellyfinTokenStore.saveSession(session), + clearStoredSession: () => (0, jellyfin_tray_discovery_1.clearJellyfinAuthSessionAndRefreshTray)(getJellyfinTrayDiscoveryDeps()), + patchJellyfinConfig: (session) => { + const clientInfo = getJellyfinClientInfo(); + const recentServers = (0, jellyfin_1.mergeJellyfinRecentServers)(session.serverUrl, getResolvedConfig().jellyfin.recentServers || []); + configService.patchRawConfig({ + jellyfin: { + enabled: true, + serverUrl: session.serverUrl, + username: session.username, + deviceId: clientInfo.deviceId, + clientName: clientInfo.clientName, + clientVersion: clientInfo.clientVersion, + recentServers, + }, + }); + refreshTrayMenuIfPresent(); + }, + persistAuthenticatedSession: (session, clientInfo) => (0, jellyfin_1.persistJellyfinAuthSession)({ + session, + clientInfo, + existingRecentServers: getResolvedConfig().jellyfin.recentServers || [], + saveStoredSession: (storedSession) => jellyfinTokenStore.saveSession(storedSession), + patchRawConfig: (patch) => { + configService.patchRawConfig(patch); + refreshTrayMenuIfPresent(); + }, + }), + logInfo: (message) => logger.info(message), + logError: (message, error) => logger.error(message, error), + showMpvOsd: (message) => showMpvOsd(message), + clearSetupWindow: () => { + appState.jellyfinSetupWindow = null; + }, + setSetupWindow: (window) => { + appState.jellyfinSetupWindow = window; + }, + encodeURIComponent: (value) => encodeURIComponent(value), + defaultServerUrl: config_2.DEFAULT_CONFIG.jellyfin.serverUrl || 'http://127.0.0.1:8096', + hasStoredSession: () => Boolean(jellyfinTokenStore.loadSession()), + }, +}); +const maybeFocusExistingFirstRunSetupWindow = (0, first_run_setup_window_1.createMaybeFocusExistingFirstRunSetupWindowHandler)({ + getSetupWindow: () => appState.firstRunSetupWindow, +}); +const openFirstRunSetupWindowHandler = (0, first_run_setup_window_1.createOpenFirstRunSetupWindowHandler)({ + maybeFocusExistingSetupWindow: maybeFocusExistingFirstRunSetupWindow, + createSetupWindow: (0, setup_window_factory_1.createCreateFirstRunSetupWindowHandler)({ + createBrowserWindow: (options) => new electron_1.BrowserWindow(options), + }), + getSetupSnapshot: async () => { + const snapshot = await firstRunSetupService.getSetupStatus(); + const mpvExecutablePath = getResolvedConfig().mpv.executablePath; + return { + configReady: snapshot.configReady, + dictionaryCount: snapshot.dictionaryCount, + canFinish: snapshot.canFinish, + externalYomitanConfigured: snapshot.externalYomitanConfigured, + pluginStatus: snapshot.pluginStatus, + pluginInstallPathSummary: snapshot.pluginInstallPathSummary, + legacyMpvPluginPaths: snapshot.legacyMpvPluginPaths, + mpvExecutablePath, + mpvExecutablePathStatus: (0, windows_mpv_launch_1.getConfiguredWindowsMpvPathStatus)(mpvExecutablePath), + windowsMpvShortcuts: snapshot.windowsMpvShortcuts, + commandLineLauncher: snapshot.commandLineLauncher, + message: firstRunSetupMessage, + }; + }, + buildSetupHtml: (model) => (0, first_run_setup_window_1.buildFirstRunSetupHtml)(model), + parseSubmissionUrl: (rawUrl) => (0, first_run_setup_window_1.parseFirstRunSetupSubmissionUrl)(rawUrl), + handleAction: async (submission) => { + if (submission.action === 'remove-legacy-plugin') { + const snapshot = await firstRunSetupService.removeLegacyMpvPlugin(); + firstRunSetupMessage = snapshot.message; + return; + } + if (submission.action === 'configure-mpv-executable-path') { + const mpvExecutablePath = submission.mpvExecutablePath?.trim() ?? ''; + const pathStatus = (0, windows_mpv_launch_1.getConfiguredWindowsMpvPathStatus)(mpvExecutablePath); + configService.patchRawConfig({ + mpv: { + executablePath: mpvExecutablePath, + }, + }); + firstRunSetupMessage = + pathStatus === 'invalid' + ? `Saved mpv executable path, but the file was not found: ${mpvExecutablePath}` + : mpvExecutablePath + ? `Saved mpv executable path: ${mpvExecutablePath}` + : 'Cleared mpv executable path. SubMiner will auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.'; + return; + } + if (submission.action === 'configure-windows-mpv-shortcuts') { + const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({ + startMenuEnabled: submission.startMenuEnabled === true, + desktopEnabled: submission.desktopEnabled === true, + }); + firstRunSetupMessage = snapshot.message; + return; + } + if (submission.action === 'install-bun') { + const snapshot = await firstRunSetupService.installBun(); + firstRunSetupMessage = snapshot.message; + return; + } + if (submission.action === 'install-command-line-launcher') { + const snapshot = await firstRunSetupService.installCommandLineLauncher(); + firstRunSetupMessage = snapshot.message; + return; + } + if (submission.action === 'open-yomitan-settings') { + firstRunSetupMessage = openYomitanSettings() + ? 'Opened Yomitan settings. Install dictionaries, then refresh status.' + : 'Yomitan settings are unavailable while external read-only profile mode is enabled.'; + return; + } + if (submission.action === 'refresh') { + const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.'); + firstRunSetupMessage = snapshot.message; + return; + } + const snapshot = await firstRunSetupService.markSetupCompleted(); + if (snapshot.state.status === 'completed') { + firstRunSetupMessage = null; + return { closeWindow: true }; + } + firstRunSetupMessage = + (0, first_run_setup_service_1.getFirstRunSetupCompletionMessage)(snapshot) ?? + 'Finish setup requires the mpv plugin and Yomitan dictionaries.'; + return; + }, + markSetupInProgress: async () => { + firstRunSetupMessage = null; + await firstRunSetupService.markSetupInProgress(); + }, + markSetupCancelled: async () => { + firstRunSetupMessage = null; + await firstRunSetupService.markSetupCancelled(); + }, + isSetupCompleted: () => firstRunSetupService.isSetupCompleted(), + shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode, + shouldQuitWhenClosedCompleted: () => Boolean(appState.initialArgs && (0, first_run_setup_service_1.isStandaloneFirstRunSetupCommand)(appState.initialArgs)), + quitApp: () => requestAppQuit(), + clearSetupWindow: () => { + appState.firstRunSetupWindow = null; + }, + setSetupWindow: (window) => { + appState.firstRunSetupWindow = window; + }, + encodeURIComponent: (value) => encodeURIComponent(value), + logError: (message, error) => logger.error(message, error), +}); +function openFirstRunSetupWindow(force = false) { + if (!force && firstRunSetupService.isSetupCompleted()) { + return; + } + openFirstRunSetupWindowHandler(); +} +const { notifyAnilistSetup, consumeAnilistSetupTokenFromUrl, handleAnilistSetupProtocolUrl, registerSubminerProtocolClient, } = (0, composers_1.composeAnilistSetupHandlers)({ + notifyDeps: { + hasMpvClient: () => Boolean(appState.mpvClient), + showMpvOsd: (message) => showMpvOsd(message), + showDesktopNotification: (title, options) => (0, utils_2.showDesktopNotification)(title, options), + logInfo: (message) => logger.info(message), + }, + consumeTokenDeps: { + consumeAnilistSetupCallbackUrl: anilist_1.consumeAnilistSetupCallbackUrl, + saveToken: (token) => anilistTokenStore.saveToken(token), + setCachedToken: (token) => { + anilistCachedAccessToken = token; + }, + setResolvedState: (resolvedAt) => { + anilistStateRuntime.setClientSecretState({ + status: 'resolved', + source: 'stored', + message: 'saved token from AniList login', + resolvedAt, + errorAt: null, + }); + }, + setSetupPageOpened: (opened) => { + appState.anilistSetupPageOpened = opened; + }, + onSuccess: () => { + notifyAnilistSetup('AniList login success'); + }, + closeWindow: () => { + if (appState.anilistSetupWindow && !appState.anilistSetupWindow.isDestroyed()) { + appState.anilistSetupWindow.close(); + } + }, + }, + handleProtocolDeps: { + consumeAnilistSetupTokenFromUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl), + logWarn: (message, details) => logger.warn(message, details), + }, + registerProtocolClientDeps: { + isDefaultApp: () => Boolean(process.defaultApp), + getArgv: () => process.argv, + execPath: process.execPath, + resolvePath: (value) => path.resolve(value), + setAsDefaultProtocolClient: (scheme, appPath, args) => appPath + ? electron_1.app.setAsDefaultProtocolClient(scheme, appPath, args) + : electron_1.app.setAsDefaultProtocolClient(scheme), + logDebug: (message, details) => logger.debug(message, details), + }, +}); +const maybeFocusExistingAnilistSetupWindow = (0, anilist_1.createMaybeFocusExistingAnilistSetupWindowHandler)({ + getSetupWindow: () => appState.anilistSetupWindow, +}); +const buildOpenAnilistSetupWindowMainDepsHandler = (0, anilist_1.createBuildOpenAnilistSetupWindowMainDepsHandler)({ + maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow, + createSetupWindow: (0, setup_window_factory_1.createCreateAnilistSetupWindowHandler)({ + createBrowserWindow: (options) => new electron_1.BrowserWindow(options), + }), + buildAuthorizeUrl: () => (0, anilist_1.buildAnilistSetupUrl)({ + authorizeUrl: ANILIST_SETUP_CLIENT_ID_URL, + clientId: ANILIST_DEFAULT_CLIENT_ID, + responseType: ANILIST_SETUP_RESPONSE_TYPE, + }), + consumeCallbackUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl), + openSetupInBrowser: (authorizeUrl) => (0, anilist_1.openAnilistSetupInBrowser)({ + authorizeUrl, + openExternal: (url) => electron_1.shell.openExternal(url), + logError: (message, error) => logger.error(message, error), + }), + loadManualTokenEntry: (setupWindow, authorizeUrl) => (0, anilist_1.loadAnilistManualTokenEntry)({ + setupWindow: setupWindow, + authorizeUrl, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + logWarn: (message, data) => logger.warn(message, data), + }), + redirectUri: ANILIST_REDIRECT_URI, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + isAllowedExternalUrl: (url) => (0, anilist_url_guard_1.isAllowedAnilistExternalUrl)(url), + isAllowedNavigationUrl: (url) => (0, anilist_url_guard_1.isAllowedAnilistSetupNavigationUrl)(url), + logWarn: (message, details) => logger.warn(message, details), + logError: (message, details) => logger.error(message, details), + clearSetupWindow: () => { + appState.anilistSetupWindow = null; + }, + setSetupPageOpened: (opened) => { + appState.anilistSetupPageOpened = opened; + }, + setSetupWindow: (setupWindow) => { + appState.anilistSetupWindow = setupWindow; + }, + openExternal: (url) => { + void electron_1.shell.openExternal(url); + }, +}); +function openAnilistSetupWindow() { + (0, anilist_1.createOpenAnilistSetupWindowHandler)(buildOpenAnilistSetupWindowMainDepsHandler())(); +} +const { refreshAnilistClientSecretState, getCurrentAnilistMediaKey, resetAnilistMediaTracking, getAnilistMediaGuessRuntimeState, setAnilistMediaGuessRuntimeState, recordAnilistMediaDuration, resetAnilistMediaGuessState, maybeProbeAnilistDuration, ensureAnilistMediaGuess, processNextAnilistRetryUpdate, maybeRunAnilistPostWatchUpdate, } = (0, composers_1.composeAnilistTrackingHandlers)({ + refreshClientSecretMainDeps: { + getResolvedConfig: () => getResolvedConfig(), + isAnilistTrackingEnabled: (config) => (0, anilist_1.isAnilistTrackingEnabled)(config), + getCachedAccessToken: () => anilistCachedAccessToken, + setCachedAccessToken: (token) => { + anilistCachedAccessToken = token; + }, + saveStoredToken: (token) => { + anilistTokenStore.saveToken(token); + }, + loadStoredToken: () => anilistTokenStore.loadToken(), + setClientSecretState: (state) => { + anilistStateRuntime.setClientSecretState(state); + }, + getAnilistSetupPageOpened: () => appState.anilistSetupPageOpened, + setAnilistSetupPageOpened: (opened) => { + appState.anilistSetupPageOpened = opened; + }, + openAnilistSetupWindow: () => { + openAnilistSetupWindow(); + }, + now: () => Date.now(), + }, + getCurrentMediaKeyMainDeps: { + getCurrentMediaPath: () => appState.currentMediaPath, + }, + resetMediaTrackingMainDeps: { + setMediaKey: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { mediaKey: value }); + }, + setMediaDurationSec: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { mediaDurationSec: value }); + }, + setMediaGuess: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { mediaGuess: value }); + }, + setMediaGuessPromise: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { mediaGuessPromise: value }); + }, + setLastDurationProbeAtMs: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { lastDurationProbeAtMs: value }); + }, + }, + getMediaGuessRuntimeStateMainDeps: { + getMediaKey: () => anilistMediaGuessRuntimeState.mediaKey, + getMediaDurationSec: () => anilistMediaGuessRuntimeState.mediaDurationSec, + getMediaGuess: () => anilistMediaGuessRuntimeState.mediaGuess, + getMediaGuessPromise: () => anilistMediaGuessRuntimeState.mediaGuessPromise, + getLastDurationProbeAtMs: () => anilistMediaGuessRuntimeState.lastDurationProbeAtMs, + }, + setMediaGuessRuntimeStateMainDeps: { + setMediaKey: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { mediaKey: value }); + }, + setMediaDurationSec: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { mediaDurationSec: value }); + }, + setMediaGuess: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { mediaGuess: value }); + }, + setMediaGuessPromise: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { mediaGuessPromise: value }); + }, + setLastDurationProbeAtMs: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { lastDurationProbeAtMs: value }); + }, + }, + recordMediaDurationMainDeps: { + getCurrentMediaKey: () => getCurrentAnilistMediaKey(), + getState: () => getAnilistMediaGuessRuntimeState(), + setState: (state) => { + setAnilistMediaGuessRuntimeState(state); + }, + }, + resetMediaGuessStateMainDeps: { + setMediaGuess: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { mediaGuess: value }); + }, + setMediaGuessPromise: (value) => { + anilistMediaGuessRuntimeState = (0, state_1.transitionAnilistMediaGuessRuntimeState)(anilistMediaGuessRuntimeState, { mediaGuessPromise: value }); + }, + }, + maybeProbeDurationMainDeps: { + getState: () => getAnilistMediaGuessRuntimeState(), + setState: (state) => { + setAnilistMediaGuessRuntimeState(state); + }, + durationRetryIntervalMs: ANILIST_DURATION_RETRY_INTERVAL_MS, + now: () => Date.now(), + requestMpvDuration: async () => appState.mpvClient?.requestProperty('duration'), + logWarn: (message, error) => logger.warn(message, error), + }, + ensureMediaGuessMainDeps: { + getState: () => getAnilistMediaGuessRuntimeState(), + setState: (state) => { + setAnilistMediaGuessRuntimeState(state); + }, + resolveMediaPathForJimaku: (currentMediaPath) => mediaRuntime.resolveMediaPathForJimaku(currentMediaPath), + getCurrentMediaPath: () => appState.currentMediaPath, + getCurrentMediaTitle: () => appState.currentMediaTitle, + guessAnilistMediaInfo: (mediaPath, mediaTitle) => (0, anilist_updater_1.guessAnilistMediaInfo)(mediaPath, mediaTitle), + }, + processNextRetryUpdateMainDeps: { + nextReady: () => anilistUpdateQueue.nextReady(), + refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), + setLastAttemptAt: (value) => { + appState.anilistRetryQueueState = (0, state_1.transitionAnilistRetryQueueLastAttemptAt)(appState.anilistRetryQueueState, value); + }, + setLastError: (value) => { + appState.anilistRetryQueueState = (0, state_1.transitionAnilistRetryQueueLastError)(appState.anilistRetryQueueState, value); + }, + refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), + updateAnilistPostWatchProgress: (accessToken, title, episode, season) => (0, anilist_updater_1.updateAnilistPostWatchProgress)(accessToken, title, episode, { + rateLimiter: anilistRateLimiter, + season, + }), + markSuccess: (key) => { + anilistUpdateQueue.markSuccess(key); + }, + rememberAttemptedUpdateKey: (key) => { + rememberAnilistAttemptedUpdate(key); + }, + markFailure: (key, message) => { + anilistUpdateQueue.markFailure(key, message); + }, + logInfo: (message) => logger.info(message), + now: () => Date.now(), + }, + maybeRunPostWatchUpdateMainDeps: { + getInFlight: () => anilistUpdateInFlightState.inFlight, + setInFlight: (value) => { + anilistUpdateInFlightState = (0, state_1.transitionAnilistUpdateInFlightState)(anilistUpdateInFlightState, value); + }, + getResolvedConfig: () => getResolvedConfig(), + isAnilistTrackingEnabled: (config) => (0, anilist_1.isAnilistTrackingEnabled)(config), + getCurrentMediaKey: () => getCurrentAnilistMediaKey(), + hasMpvClient: () => Boolean(appState.mpvClient), + getTrackedMediaKey: () => anilistMediaGuessRuntimeState.mediaKey, + resetTrackedMedia: (mediaKey) => { + resetAnilistMediaTracking(mediaKey); + }, + getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN, + maybeProbeAnilistDuration: (mediaKey, options) => maybeProbeAnilistDuration(mediaKey, options), + ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey), + hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key), + processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), + refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), + enqueueRetry: (key, title, episode, season) => { + anilistUpdateQueue.enqueue(key, title, episode, season); + }, + markRetryFailure: (key, message) => { + anilistUpdateQueue.markFailure(key, message); + }, + markRetrySuccess: (key) => { + anilistUpdateQueue.markSuccess(key); + }, + refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), + updateAnilistPostWatchProgress: (accessToken, title, episode, season) => (0, anilist_updater_1.updateAnilistPostWatchProgress)(accessToken, title, episode, { + rateLimiter: anilistRateLimiter, + season, + }), + rememberAttemptedUpdateKey: (key) => { + rememberAnilistAttemptedUpdate(key); + }, + showMpvOsd: (message) => showMpvOsd(message), + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), + minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS, + minWatchRatio: watch_threshold_1.DEFAULT_MIN_WATCH_RATIO, + }, +}); +function refreshAnilistClientSecretStateIfEnabled(options) { + if (!(0, anilist_1.isAnilistTrackingEnabled)(getResolvedConfig())) { + return Promise.resolve(null); + } + return refreshAnilistClientSecretState(options); +} +const rememberAnilistAttemptedUpdate = (key) => { + (0, anilist_1.rememberAnilistAttemptedUpdateKey)(anilistAttemptedUpdateKeys, key, ANILIST_MAX_ATTEMPTED_UPDATE_KEYS); +}; +const buildLoadSubtitlePositionMainDepsHandler = (0, overlay_1.createBuildLoadSubtitlePositionMainDepsHandler)({ + loadSubtitlePositionCore: () => (0, services_1.loadSubtitlePosition)({ + currentMediaPath: appState.currentMediaPath, + fallbackPosition: getResolvedConfig().subtitlePosition, + subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, + }), + setSubtitlePosition: (position) => { + appState.subtitlePosition = position; + }, +}); +const loadSubtitlePositionMainDeps = buildLoadSubtitlePositionMainDepsHandler(); +const loadSubtitlePosition = (0, overlay_1.createLoadSubtitlePositionHandler)(loadSubtitlePositionMainDeps); +const buildSaveSubtitlePositionMainDepsHandler = (0, overlay_1.createBuildSaveSubtitlePositionMainDepsHandler)({ + saveSubtitlePositionCore: (position) => { + (0, services_1.saveSubtitlePosition)({ + position, + currentMediaPath: appState.currentMediaPath, + subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, + onQueuePending: (queued) => { + appState.pendingSubtitlePosition = queued; + }, + onPersisted: () => { + appState.pendingSubtitlePosition = null; + }, + }); + }, + setSubtitlePosition: (position) => { + appState.subtitlePosition = position; + }, +}); +const saveSubtitlePositionMainDeps = buildSaveSubtitlePositionMainDepsHandler(); +const saveSubtitlePosition = (0, overlay_1.createSaveSubtitlePositionHandler)(saveSubtitlePositionMainDeps); +registerSubminerProtocolClient(); +let flushPendingMpvLogWrites = () => { }; +const { registerProtocolUrlHandlers: registerProtocolUrlHandlersHandler, onWillQuitCleanup: onWillQuitCleanupHandler, shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler, restoreWindowsOnActivate: restoreWindowsOnActivateHandler, } = (0, composers_1.composeStartupLifecycleHandlers)({ + registerProtocolUrlHandlersMainDeps: { + registerOpenUrl: (listener) => { + electron_1.app.on('open-url', listener); + }, + registerSecondInstance: (listener) => { + (0, early_single_instance_1.registerSecondInstanceHandlerEarly)(electron_1.app, listener); + }, + handleAnilistSetupProtocolUrl: (rawUrl) => handleAnilistSetupProtocolUrl(rawUrl), + findAnilistSetupDeepLinkArgvUrl: (argv) => (0, anilist_1.findAnilistSetupDeepLinkArgvUrl)(argv), + logUnhandledOpenUrl: (rawUrl) => { + logger.warn('Unhandled app protocol URL', { rawUrl }); + }, + logUnhandledSecondInstanceUrl: (rawUrl) => { + logger.warn('Unhandled second-instance protocol URL', { rawUrl }); + }, + }, + onWillQuitCleanupMainDeps: { + destroyTray: () => destroyTray(), + stopConfigHotReload: () => configHotReloadRuntime.stop(), + restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(), + restoreMpvSubVisibility: () => { + restoreOverlayMpvSubtitles(); + }, + unregisterAllGlobalShortcuts: () => electron_1.globalShortcut.unregisterAll(), + stopSubtitleWebsocket: () => { + subtitleWsService.stop(); + annotationSubtitleWsService.stop(); + }, + stopTexthookerService: () => texthookerService.stop(), + clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop(), + clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => { + cancelLinuxMpvFullscreenOverlayRefreshBurst = null; + (0, linux_mpv_fullscreen_overlay_refresh_1.clearLinuxMpvFullscreenOverlayRefreshTimeouts)(); + }, + getMainOverlayWindow: () => overlayManager.getMainWindow(), + clearMainOverlayWindow: () => overlayManager.setMainWindow(null), + getModalOverlayWindow: () => overlayManager.getModalWindow(), + clearModalOverlayWindow: () => overlayManager.setModalWindow(null), + getYomitanParserWindow: () => appState.yomitanParserWindow, + clearYomitanParserState: () => { + appState.yomitanParserWindow = null; + appState.yomitanParserReadyPromise = null; + appState.yomitanParserInitPromise = null; + appState.yomitanSession = null; + }, + getWindowTracker: () => appState.windowTracker, + flushMpvLog: () => flushPendingMpvLogWrites(), + getMpvSocket: () => appState.mpvClient?.socket ?? null, + getReconnectTimer: () => appState.reconnectTimer, + clearReconnectTimerRef: () => { + appState.reconnectTimer = null; + }, + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getImmersionTracker: () => appState.immersionTracker, + clearImmersionTracker: () => { + stopStatsServer(); + appState.statsServer = null; + appState.immersionTracker = null; + }, + getAnkiIntegration: () => appState.ankiIntegration, + getAnilistSetupWindow: () => appState.anilistSetupWindow, + clearAnilistSetupWindow: () => { + appState.anilistSetupWindow = null; + }, + getJellyfinSetupWindow: () => appState.jellyfinSetupWindow, + clearJellyfinSetupWindow: () => { + appState.jellyfinSetupWindow = null; + }, + getFirstRunSetupWindow: () => appState.firstRunSetupWindow, + clearFirstRunSetupWindow: () => { + appState.firstRunSetupWindow = null; + }, + getYomitanSettingsWindow: () => appState.yomitanSettingsWindow, + clearYomitanSettingsWindow: () => { + appState.yomitanSettingsWindow = null; + }, + stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), + stopDiscordPresenceService: () => { + void appState.discordPresenceService?.stop(); + appState.discordPresenceService = null; + }, + }, + shouldRestoreWindowsOnActivateMainDeps: { + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + getAllWindowCount: () => electron_1.BrowserWindow.getAllWindows().length, + }, + restoreWindowsOnActivateMainDeps: { + createMainWindow: () => { + createMainWindow(); + }, + updateVisibleOverlayVisibility: () => { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, + syncOverlayMpvSubtitleSuppression: () => { + syncOverlayMpvSubtitleSuppression(); + }, + }, +}); +registerProtocolUrlHandlersHandler(); +const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); +const statsPreloadPath = path.join(__dirname, 'preload-stats.js'); +const startLocalStatsServer = () => { + const tracker = appState.immersionTracker; + if (!tracker) { + throw new Error('Immersion tracker failed to initialize.'); + } + if (!statsServer) { + const yomitanDeps = { + getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (w) => { + appState.yomitanParserWindow = w; + }, + getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (p) => { + appState.yomitanParserReadyPromise = p; + }, + getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (p) => { + appState.yomitanParserInitPromise = p; + }, + }; + const yomitanLogger = (0, logger_1.createLogger)('main:yomitan-stats'); + statsServer = (0, stats_server_1.startStatsServer)({ + port: getResolvedConfig().stats.serverPort, + staticDir: statsDistPath, + tracker, + knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'), + mpvSocketPath: appState.mpvSocketPath, + ankiConnectConfig: getResolvedConfig().ankiConnect, + anilistRateLimiter, + resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, + addYomitanNote: async (word) => { + const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; + await (0, services_1.syncYomitanDefaultAnkiServer)(ankiUrl, yomitanDeps, yomitanLogger, { + forceOverride: true, + }); + const result = await (0, services_1.addYomitanNoteViaSearch)(word, yomitanDeps, yomitanLogger); + if (result.noteId && result.duplicateNoteIds.length > 0) { + appState.ankiIntegration?.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds); + } + return result.noteId; + }, + }); + appState.statsServer = statsServer; + } + appState.statsServer = statsServer; +}; +const ensureStatsServerStarted = (0, stats_server_routing_1.createEnsureStatsServerUrlHandler)({ + currentPid: process.pid, + readBackgroundState: () => (0, stats_daemon_1.readBackgroundStatsServerState)(statsDaemonStatePath), + removeBackgroundState: () => { + (0, stats_daemon_1.removeBackgroundStatsServerState)(statsDaemonStatePath); + }, + isProcessAlive: (pid) => (0, stats_daemon_1.isBackgroundStatsServerProcessAlive)(pid), + hasLocalStatsServer: () => statsServer !== null, + startLocalStatsServer, + getConfiguredPort: () => getResolvedConfig().stats.serverPort, +}); +const ensureBackgroundStatsServerStarted = () => { + const liveDaemon = readLiveBackgroundStatsDaemonState(); + if (liveDaemon && liveDaemon.pid !== process.pid) { + return { + url: (0, stats_daemon_1.resolveBackgroundStatsServerUrl)(liveDaemon), + runningInCurrentProcess: false, + }; + } + appState.statsStartupInProgress = true; + try { + ensureImmersionTrackerStarted(); + } + finally { + appState.statsStartupInProgress = false; + } + const port = getResolvedConfig().stats.serverPort; + const result = ensureStatsServerStarted(); + if (result.source === 'local') { + (0, stats_daemon_1.writeBackgroundStatsServerState)(statsDaemonStatePath, { + pid: process.pid, + port, + startedAtMs: Date.now(), + }); + } + return { url: result.url, runningInCurrentProcess: result.source === 'local' }; +}; +const stopBackgroundStatsServer = async () => { + const state = (0, stats_daemon_1.readBackgroundStatsServerState)(statsDaemonStatePath); + if (!state) { + (0, stats_daemon_1.removeBackgroundStatsServerState)(statsDaemonStatePath); + return { ok: true, stale: true }; + } + if (!(0, stats_daemon_1.isBackgroundStatsServerProcessAlive)(state.pid)) { + (0, stats_daemon_1.removeBackgroundStatsServerState)(statsDaemonStatePath); + return { ok: true, stale: true }; + } + try { + process.kill(state.pid, 'SIGTERM'); + } + catch (error) { + if (error?.code === 'ESRCH') { + (0, stats_daemon_1.removeBackgroundStatsServerState)(statsDaemonStatePath); + return { ok: true, stale: true }; + } + if (error?.code === 'EPERM') { + throw new Error(`Insufficient permissions to stop background stats server (pid ${state.pid}).`); + } + throw error; + } + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (!(0, stats_daemon_1.isBackgroundStatsServerProcessAlive)(state.pid)) { + (0, stats_daemon_1.removeBackgroundStatsServerState)(statsDaemonStatePath); + return { ok: true, stale: false }; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('Timed out stopping background stats server.'); +}; +const resolveLegacyVocabularyPos = async (row) => { + const tokenizer = appState.mecabTokenizer; + if (!tokenizer) { + return null; + } + const lookupTexts = [...new Set([row.headword, row.word, row.reading ?? ''])] + .map((value) => value.trim()) + .filter((value) => value.length > 0); + for (const lookupText of lookupTexts) { + const tokens = await tokenizer.tokenize(lookupText); + const resolved = (0, legacy_vocabulary_pos_1.resolveLegacyVocabularyPosFromTokens)(lookupText, tokens); + if (resolved) { + return resolved; + } + } + return null; +}; +const immersionTrackerStartupMainDeps = { + getResolvedConfig: () => getResolvedConfig(), + getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), + createTrackerService: (params) => new services_1.ImmersionTrackerService({ + ...params, + resolveLegacyVocabularyPos, + }), + setTracker: (tracker) => { + const trackerHasChanged = appState.immersionTracker !== null && appState.immersionTracker !== tracker; + if (trackerHasChanged && appState.statsServer) { + stopStatsServer(); + appState.statsServer = null; + } + appState.immersionTracker = tracker; + appState.immersionTracker?.setCoverArtFetcher(statsCoverArtFetcher); + if (tracker) { + // Start HTTP stats server + if (!appState.statsServer) { + const config = getResolvedConfig(); + if (config.stats.autoStartServer) { + ensureStatsServerStarted(); + } + } + // Register stats overlay toggle IPC handler (idempotent) + (0, stats_window_js_1.registerStatsOverlayToggle)({ + staticDir: statsDistPath, + preloadPath: statsPreloadPath, + getApiBaseUrl: () => ensureStatsServerStarted().url, + getToggleKey: () => getResolvedConfig().stats.toggleKey, + resolveBounds: () => getCurrentOverlayGeometry(), + onVisibilityChanged: (visible) => { + appState.statsOverlayVisible = visible; + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, + }); + } + }, + getMpvClient: () => appState.mpvClient, + shouldAutoConnectMpv: () => !appState.statsStartupInProgress, + seedTrackerFromCurrentMedia: () => { + void immersionMediaRuntime.seedFromCurrentMedia(); + }, + logInfo: (message) => logger.info(message), + logDebug: (message) => logger.debug(message), + logWarn: (message, details) => logger.warn(message, details), +}; +const createImmersionTrackerStartup = (0, immersion_startup_1.createImmersionTrackerStartupHandler)((0, immersion_startup_main_deps_1.createBuildImmersionTrackerStartupMainDepsHandler)(immersionTrackerStartupMainDeps)()); +const recordTrackedCardsMined = (count, noteIds) => { + ensureImmersionTrackerStarted(); + appState.immersionTracker?.recordCardsMined(count, noteIds); +}; +const refreshCurrentSubtitleAfterKnownWordUpdate = () => { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); +}; +let hasAttemptedImmersionTrackerStartup = false; +const ensureImmersionTrackerStarted = () => { + if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) { + return; + } + hasAttemptedImmersionTrackerStartup = true; + createImmersionTrackerStartup(); +}; +const statsStartupRuntime = { + ensureStatsServerStarted: () => ensureStatsServerStarted(), + ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(), + stopBackgroundStatsServer: () => stopBackgroundStatsServer(), + ensureImmersionTrackerStarted: () => { + appState.statsStartupInProgress = true; + try { + ensureImmersionTrackerStarted(); + } + finally { + appState.statsStartupInProgress = false; + } + }, +}; +const runStatsCliCommand = (0, stats_cli_command_1.createRunStatsCliCommandHandler)({ + getResolvedConfig: () => getResolvedConfig(), + ensureImmersionTrackerStarted: () => statsStartupRuntime.ensureImmersionTrackerStarted(), + ensureVocabularyCleanupTokenizerReady: async () => { + await createMecabTokenizerAndCheck(); + }, + getImmersionTracker: () => appState.immersionTracker, + ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted().url, + ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(), + stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(), + openExternal: (url) => electron_1.shell.openExternal(url), + writeResponse: (responsePath, payload) => { + (0, stats_cli_command_1.writeStatsCliCommandResponse)(responsePath, payload); + }, + exitAppWithCode: (code) => { + process.exitCode = code; + requestAppQuit(); + }, + logInfo: (message) => logger.info(message), + logWarn: (message, error) => logger.warn(message, error), + logError: (message, error) => logger.error(message, error), +}); +async function runHeadlessInitialCommand() { + if (!appState.initialArgs?.refreshKnownWords) { + handleInitialArgs(); + return; + } + const resolvedConfig = getResolvedConfig(); + if (resolvedConfig.ankiConnect.enabled !== true) { + logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled'); + process.exitCode = 1; + requestAppQuit(); + return; + } + const effectiveAnkiConfig = appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ?? + resolvedConfig.ankiConnect; + const integration = new anki_integration_1.AnkiIntegration(effectiveAnkiConfig, new subtitle_timing_tracker_1.SubtitleTimingTracker(), { send: () => undefined }, undefined, undefined, async () => ({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: false, + cancelled: true, + }), path.join(USER_DATA_PATH, 'known-words-cache.json'), (0, config_1.mergeAiConfig)(resolvedConfig.ai, resolvedConfig.ankiConnect?.ai)); + try { + await integration.refreshKnownWordCache(); + } + catch (error) { + logger.error('Headless known-word refresh failed:', error); + process.exitCode = 1; + } + finally { + integration.stop(); + requestAppQuit(); + } +} +const { appReadyRuntimeRunner } = (0, composers_1.composeAppReadyRuntime)({ + reloadConfigMainDeps: { + reloadConfigStrict: () => configService.reloadConfigStrict(), + logInfo: (message) => appLogger.logInfo(message), + logDebug: (message) => appLogger.logDebug(message), + logWarning: (message) => appLogger.logWarning(message), + showDesktopNotification: (title, options) => (0, utils_2.showDesktopNotification)(title, options), + startConfigHotReload: () => configHotReloadRuntime.start(), + shouldRefreshAnilistClientSecretState: () => (0, startup_mode_flags_1.shouldRefreshAnilistOnConfigReload)(appState.initialArgs), + refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretStateIfEnabled(options), + failHandlers: { + logError: (details) => logger.error(details), + showErrorBox: (title, details) => electron_1.dialog.showErrorBox(title, details), + quit: () => requestAppQuit(), + }, + }, + criticalConfigErrorMainDeps: { + getConfigPath: () => configService.getConfigPath(), + failHandlers: { + logError: (message) => logger.error(message), + showErrorBox: (title, message) => electron_1.dialog.showErrorBox(title, message), + quit: () => requestAppQuit(), + }, + }, + appReadyRuntimeMainDeps: { + ensureDefaultConfigBootstrap: () => { + (0, setup_state_1.ensureDefaultConfigBootstrap)({ + configDir: CONFIG_DIR, + configFilePaths: (0, setup_state_1.getDefaultConfigFilePaths)(CONFIG_DIR), + generateTemplate: () => (0, config_2.generateConfigTemplate)(config_2.DEFAULT_CONFIG), + }); + }, + loadSubtitlePosition: () => loadSubtitlePosition(), + resolveKeybindings: () => { + appState.keybindings = (0, utils_2.resolveKeybindings)(getResolvedConfig(), config_2.DEFAULT_KEYBINDINGS); + refreshCurrentSessionBindings(); + }, + createMpvClient: () => { + appState.mpvClient = createMpvClientRuntimeService(); + }, + getResolvedConfig: () => getResolvedConfig(), + getConfigWarnings: () => configService.getWarnings(), + logConfigWarning: (warning) => appLogger.logConfigWarning(warning), + setLogLevel: (level, source) => (0, logger_1.setLogLevel)(level, source), + initRuntimeOptionsManager: () => { + appState.runtimeOptionsManager = new runtime_options_1.RuntimeOptionsManager(() => configService.getConfig().ankiConnect, { + applyAnkiPatch: (patch) => { + if (appState.ankiIntegration) { + appState.ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, + getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, + onOptionsChanged: () => { + subtitleProcessingController.invalidateTokenizationCache(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); + broadcastRuntimeOptionsChanged(); + refreshOverlayShortcuts(); + }, + }); + }, + setSecondarySubMode: (mode) => { + setSecondarySubMode(mode); + }, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: config_2.DEFAULT_CONFIG.websocket.port, + defaultAnnotationWebsocketPort: config_2.DEFAULT_CONFIG.annotationWebsocket.port, + defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, + hasMpvWebsocketPlugin: () => (0, services_1.hasMpvWebsocketPlugin)(), + startSubtitleWebsocket: (port) => { + subtitleWsService.start(port, () => appState.currentSubtitleData ?? + (appState.currentSubText + ? { + text: appState.currentSubText, + tokens: null, + startTime: appState.mpvClient?.currentSubStart ?? null, + endTime: appState.mpvClient?.currentSubEnd ?? null, + } + : null), () => ({ + enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, + mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, + })); + }, + startAnnotationWebsocket: (port) => { + annotationSubtitleWsService.start(port, () => appState.currentSubtitleData ?? + (appState.currentSubText + ? { + text: appState.currentSubText, + tokens: null, + startTime: appState.mpvClient?.currentSubStart ?? null, + endTime: appState.mpvClient?.currentSubEnd ?? null, + } + : null), () => ({ + enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, + mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, + })); + }, + startTexthooker: (port, websocketUrl) => { + if (!texthookerService.isRunning()) { + texthookerService.start(port, websocketUrl); + } + }, + log: (message) => appLogger.logInfo(message), + createMecabTokenizerAndCheck: async () => { + await createMecabTokenizerAndCheck(); + }, + createSubtitleTimingTracker: () => { + const tracker = new subtitle_timing_tracker_1.SubtitleTimingTracker(); + appState.subtitleTimingTracker = tracker; + }, + loadYomitanExtension: async () => { + await loadYomitanExtension(); + }, + ensureYomitanExtensionLoaded: async () => { + await ensureYomitanExtensionLoaded(); + }, + handleFirstRunSetup: async () => { + const snapshot = await firstRunSetupService.ensureSetupStateInitialized(); + appState.firstRunSetupCompleted = snapshot.state.status === 'completed'; + const args = appState.initialArgs; + if (args && (0, first_run_setup_service_1.shouldAutoOpenFirstRunSetup)(args)) { + const force = Boolean(args.setup); + if (force || snapshot.state.status !== 'completed') { + openFirstRunSetupWindow(force); + } + } + }, + startJellyfinRemoteSession: async () => { + await startJellyfinRemoteSession(); + }, + prewarmSubtitleDictionaries: async () => { + await prewarmSubtitleDictionaries(); + }, + startBackgroundWarmups: () => { + startBackgroundWarmupsIfAllowed(); + }, + texthookerOnlyMode: appState.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: () => appState.backgroundMode + ? false + : configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + runHeadlessInitialCommand: () => runHeadlessInitialCommand(), + handleInitialArgs: () => handleInitialArgs(), + shouldRunHeadlessInitialCommand: () => Boolean(appState.initialArgs && (0, args_1.isHeadlessInitialCommand)(appState.initialArgs)), + shouldUseMinimalStartup: () => (0, startup_mode_flags_1.getStartupModeFlags)(appState.initialArgs).shouldUseMinimalStartup, + shouldSkipHeavyStartup: () => (0, startup_mode_flags_1.getStartupModeFlags)(appState.initialArgs).shouldSkipHeavyStartup, + createImmersionTracker: () => { + ensureImmersionTrackerStarted(); + }, + logDebug: (message) => { + logger.debug(message); + }, + now: () => Date.now(), + }, + immersionTrackerStartupMainDeps, +}); +function ensureOverlayStartupPrereqs() { + if (appState.subtitlePosition === null) { + loadSubtitlePosition(); + } + if (appState.keybindings.length === 0) { + appState.keybindings = (0, utils_2.resolveKeybindings)(getResolvedConfig(), config_2.DEFAULT_KEYBINDINGS); + refreshCurrentSessionBindings(); + } + else if (!appState.sessionBindingsInitialized) { + refreshCurrentSessionBindings(); + } + if (!appState.mpvClient) { + appState.mpvClient = createMpvClientRuntimeService(); + } + if (!appState.runtimeOptionsManager) { + appState.runtimeOptionsManager = new runtime_options_1.RuntimeOptionsManager(() => configService.getConfig().ankiConnect, { + applyAnkiPatch: (patch) => { + if (appState.ankiIntegration) { + appState.ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, + getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, + onOptionsChanged: () => { + subtitleProcessingController.invalidateTokenizationCache(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); + broadcastRuntimeOptionsChanged(); + refreshOverlayShortcuts(); + }, + }); + } + if (!appState.subtitleTimingTracker) { + appState.subtitleTimingTracker = new subtitle_timing_tracker_1.SubtitleTimingTracker(); + } +} +async function ensureYoutubePlaybackRuntimeReady() { + ensureOverlayStartupPrereqs(); + await ensureYomitanExtensionLoaded(); + if (!appState.overlayRuntimeInitialized) { + initializeOverlayRuntime(); + return; + } + ensureOverlayWindowsReadyForVisibilityActions(); +} +const { createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, tokenizeSubtitle, createMecabTokenizerAndCheck, prewarmSubtitleDictionaries, startBackgroundWarmups, startTokenizationWarmups, isTokenizationWarmupReady, } = (0, composers_1.composeMpvRuntimeHandlers)({ + bindMpvMainEventHandlersMainDeps: { + appState, + getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed || youtubePlaybackRuntime.getQuitOnDisconnectArmed(), + scheduleQuitCheck: (callback) => { + setTimeout(callback, 500); + }, + quitApp: () => requestAppQuit(), + reportJellyfinRemoteStopped: () => { + void reportJellyfinRemoteStopped(); + }, + maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options), + recordAnilistMediaDuration: (durationSec) => { + recordAnilistMediaDuration(durationSec); + }, + logSubtitleTimingError: (message, error) => logger.error(message, error), + broadcastToOverlayWindows: (channel, payload) => { + broadcastToOverlayWindows(channel, payload); + }, + getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text), + emitImmediateSubtitle: (payload) => { + emitSubtitlePayload(payload); + }, + onSubtitleChange: (text) => { + subtitlePrefetchService?.pause(); + subtitleProcessingController.onSubtitleChange(text); + }, + refreshDiscordPresence: () => { + discordPresenceRuntime.publishDiscordPresence(); + }, + ensureImmersionTrackerInitialized: () => { + ensureImmersionTrackerStarted(); + }, + tokenizeSubtitleForImmersion: async (text) => tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, + updateCurrentMediaPath: (path) => { + autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); + currentMediaTokenizationGate.updateCurrentMediaPath(path); + managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); + startupOsdSequencer.reset(); + subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh(); + subtitlePrefetchRuntime.cancelPendingInit(); + youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path); + if (path) { + ensureImmersionTrackerStarted(); + // Delay slightly to allow MPV's track-list to be populated. + subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500); + } + mediaRuntime.updateCurrentMediaPath(path); + }, + restoreMpvSubVisibility: () => { + restoreOverlayMpvSubtitles(); + }, + resetSubtitleSidebarEmbeddedLayout: () => { + resetSubtitleSidebarEmbeddedLayoutRuntime(); + }, + getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey) => { + resetAnilistMediaTracking(mediaKey); + }, + maybeProbeAnilistDuration: (mediaKey) => { + void maybeProbeAnilistDuration(mediaKey); + }, + ensureAnilistMediaGuess: (mediaKey) => { + void ensureAnilistMediaGuess(mediaKey); + }, + syncImmersionMediaState: () => { + immersionMediaRuntime.syncFromCurrentMediaState(); + }, + signalAutoplayReadyIfWarm: () => { + if (!isTokenizationWarmupReady()) { + return; + } + autoplayReadyGate.maybeSignalPluginAutoplayReady({ text: '__warm__', tokens: null }, { forceWhilePaused: true }); + }, + scheduleCharacterDictionarySync: () => { + if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { + return; + } + characterDictionaryAutoSyncRuntime.scheduleSync(); + }, + updateCurrentMediaTitle: (title) => { + mediaRuntime.updateCurrentMediaTitle(title); + }, + resetAnilistMediaGuessState: () => { + resetAnilistMediaGuessState(); + }, + reportJellyfinRemoteProgress: (forceImmediate) => { + void reportJellyfinRemoteProgress(forceImmediate); + }, + onTimePosUpdate: (time) => { + const delta = time - lastObservedTimePos; + if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) { + subtitlePrefetchService.onSeek(time); + } + lastObservedTimePos = time; + }, + onFullscreenChange: (fullscreen) => { + cancelLinuxMpvFullscreenOverlayRefreshBurst = (0, linux_mpv_fullscreen_overlay_refresh_1.updateLinuxMpvFullscreenOverlayRefreshBurst)(fullscreen, { + overlayManager: { + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }, + overlayVisibilityRuntime, + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + }, cancelLinuxMpvFullscreenOverlayRefreshBurst); + }, + onSubtitleTrackChange: (sid) => { + scheduleSubtitlePrefetchRefresh(); + youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid); + }, + onSubtitleTrackListChange: (trackList) => { + managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList); + scheduleSubtitlePrefetchRefresh(); + youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList); + }, + updateSubtitleRenderMetrics: (patch) => { + updateMpvSubtitleRenderMetrics(patch); + }, + syncOverlayMpvSubtitleSuppression: () => { + syncOverlayMpvSubtitleSuppression(); + }, + }, + mpvClientRuntimeServiceFactoryMainDeps: { + createClient: services_1.MpvIpcClient, + getSocketPath: () => appState.mpvSocketPath, + getResolvedConfig: () => getResolvedConfig(), + isAutoStartOverlayEnabled: () => appState.autoStartOverlay, + setOverlayVisible: (visible) => setOverlayVisible(visible), + isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getReconnectTimer: () => appState.reconnectTimer, + setReconnectTimer: (timer) => { + appState.reconnectTimer = timer; + }, + shouldQuitOnMpvShutdown: () => appState.initialArgs?.managedPlayback === true, + requestAppQuit: () => requestAppQuit(), + }, + updateMpvSubtitleRenderMetricsMainDeps: { + getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics, + setCurrentMetrics: (metrics) => { + appState.mpvSubtitleRenderMetrics = metrics; + }, + applyPatch: (current, patch) => (0, services_1.applyMpvSubtitleRenderMetricsPatch)(current, patch), + broadcastMetrics: () => { + // no renderer consumer for subtitle render metrics updates at present + }, + }, + tokenizer: { + buildTokenizerDepsMainDeps: { + getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + appState.yomitanParserWindow = window; + }, + getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + appState.yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + appState.yomitanParserInitPromise = promise; + }, + isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)), + recordLookup: (hit) => { + ensureImmersionTrackerStarted(); + appState.immersionTracker?.recordLookup(hit); + }, + getKnownWordMatchMode: () => appState.ankiIntegration?.getKnownWordMatchMode() ?? + getResolvedConfig().ankiConnect.knownWords.matchMode, + getNPlusOneEnabled: () => getRuntimeBooleanOption('subtitle.annotation.nPlusOne', getResolvedConfig().ankiConnect.knownWords.highlightEnabled), + getMinSentenceWordsForNPlusOne: () => getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, + getJlptLevel: (text) => appState.jlptLevelLookup(text), + getJlptEnabled: () => getRuntimeBooleanOption('subtitle.annotation.jlpt', getResolvedConfig().subtitleStyle.enableJlpt), + getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled && + yomitanProfilePolicy.isCharacterDictionaryEnabled() && + !isYoutubePlaybackActiveNow(), + getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, + getFrequencyDictionaryEnabled: () => getRuntimeBooleanOption('subtitle.annotation.frequency', getResolvedConfig().subtitleStyle.frequencyDictionary.enabled), + getFrequencyDictionaryMatchMode: () => getResolvedConfig().subtitleStyle.frequencyDictionary.matchMode, + getFrequencyRank: (text) => appState.frequencyRankLookup(text), + getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, + getMecabTokenizer: () => appState.mecabTokenizer, + onTokenizationReady: (text) => { + currentMediaTokenizationGate.markReady(appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null); + startupOsdSequencer.markTokenizationReady(); + autoplayReadyGate.maybeSignalPluginAutoplayReady({ text, tokens: null }, { forceWhilePaused: true }); + }, + }, + createTokenizerRuntimeDeps: (deps) => (0, services_1.createTokenizerDepsRuntime)(deps), + tokenizeSubtitle: (text, deps) => (0, services_1.tokenizeSubtitle)(text, deps), + createMecabTokenizerAndCheckMainDeps: { + getMecabTokenizer: () => appState.mecabTokenizer, + setMecabTokenizer: (tokenizer) => { + appState.mecabTokenizer = tokenizer; + }, + createMecabTokenizer: () => new mecab_tokenizer_1.MecabTokenizer(), + checkAvailability: async (tokenizer) => tokenizer.checkAvailability(), + }, + prewarmSubtitleDictionariesMainDeps: { + ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), + ensureFrequencyDictionaryLookup: () => frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), + showMpvOsd: (message) => showMpvOsd(message), + showLoadingOsd: (message) => startupOsdSequencer.showAnnotationLoading(message), + showLoadedOsd: (message) => startupOsdSequencer.markAnnotationLoadingComplete(message), + shouldShowOsdNotification: () => { + const type = getResolvedConfig().ankiConnect.behavior.notificationType; + return type === 'osd' || type === 'both'; + }, + }, + }, + warmups: { + launchBackgroundWarmupTaskMainDeps: { + now: () => Date.now(), + logDebug: (message) => logger.debug(message), + logWarn: (message) => logger.warn(message), + }, + startBackgroundWarmupsMainDeps: { + getStarted: () => backgroundWarmupsStarted, + setStarted: (started) => { + backgroundWarmupsStarted = started; + }, + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => { }), + shouldWarmupMecab: () => { + const startupWarmups = getResolvedConfig().startupWarmups; + if (startupWarmups.lowPowerMode) { + return false; + } + if (!startupWarmups.mecab) { + return false; + } + return shouldInitializeMecabForAnnotations(); + }, + shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension, + shouldWarmupSubtitleDictionaries: () => { + const startupWarmups = getResolvedConfig().startupWarmups; + if (startupWarmups.lowPowerMode) { + return false; + } + return startupWarmups.subtitleDictionaries; + }, + shouldWarmupJellyfinRemoteSession: () => { + const startupWarmups = getResolvedConfig().startupWarmups; + if (startupWarmups.lowPowerMode) { + return false; + } + return startupWarmups.jellyfinRemoteSession; + }, + shouldAutoConnectJellyfinRemote: () => { + const jellyfin = getResolvedConfig().jellyfin; + return (jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect); + }, + startJellyfinRemoteSession: () => startJellyfinRemoteSession(), + logDebug: (message) => logger.debug(message), + }, + }, +}); +tokenizeSubtitleDeferred = tokenizeSubtitle; +function createMpvClientRuntimeService() { + const client = createMpvClientRuntimeServiceHandler(); + client.on('connection-change', ({ connected }) => { + if (connected) { + return; + } + if (!youtubeFlowRuntime.hasActiveSession()) { + return; + } + youtubeFlowRuntime.cancelActivePicker(); + broadcastToOverlayWindows(contracts_1.IPC_CHANNELS.event.youtubePickerCancel, null); + overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker'); + }); + return client; +} +function resetSubtitleSidebarEmbeddedLayoutRuntime() { + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, ['set_property', 'video-margin-ratio-right', 0]); + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, ['set_property', 'video-pan-x', 0]); +} +function updateMpvSubtitleRenderMetrics(patch) { + updateMpvSubtitleRenderMetricsHandler(patch); +} +let lastOverlayWindowGeometry = null; +function getOverlayGeometryFallback() { + const cursorPoint = electron_1.screen.getCursorScreenPoint(); + const display = electron_1.screen.getDisplayNearestPoint(cursorPoint); + const bounds = display.workArea; + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; +} +function getCurrentOverlayGeometry() { + if (lastOverlayWindowGeometry) + return lastOverlayWindowGeometry; + const trackerGeometry = appState.windowTracker?.getGeometry(); + if (trackerGeometry) + return trackerGeometry; + return getOverlayGeometryFallback(); +} +function geometryMatches(a, b) { + if (!a || !b) + return false; + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +} +function applyOverlayRegions(geometry) { + lastOverlayWindowGeometry = geometry; + overlayManager.setOverlayWindowBounds(geometry); + overlayManager.setModalWindowBounds(geometry); +} +const buildUpdateVisibleOverlayBoundsMainDepsHandler = (0, overlay_1.createBuildUpdateVisibleOverlayBoundsMainDepsHandler)({ + setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), + afterSetOverlayWindowBounds: () => { + if (!overlayManager.getVisibleOverlayVisible()) { + return; + } + if (process.platform === 'win32') { + scheduleWindowsVisibleOverlayZOrderSyncBurst(); + return; + } + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + ensureOverlayWindowLevel(mainWindow); + }, +}); +const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); +const updateVisibleOverlayBounds = (0, overlay_1.createUpdateVisibleOverlayBoundsHandler)(updateVisibleOverlayBoundsMainDeps); +const buildEnsureOverlayWindowLevelMainDepsHandler = (0, overlay_1.createBuildEnsureOverlayWindowLevelMainDepsHandler)({ + ensureOverlayWindowLevelCore: (window) => (0, services_1.ensureOverlayWindowLevel)(window), +}); +const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler(); +const ensureOverlayWindowLevel = (0, overlay_1.createEnsureOverlayWindowLevelHandler)(ensureOverlayWindowLevelMainDeps); +function syncPrimaryOverlayWindowLayer(layer) { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) + return; + (0, services_1.syncOverlayWindowLayer)(mainWindow, layer); +} +const buildEnforceOverlayLayerOrderMainDepsHandler = (0, overlay_1.createBuildEnforceOverlayLayerOrderMainDepsHandler)({ + enforceOverlayLayerOrderCore: (params) => (0, services_1.enforceOverlayLayerOrder)({ + visibleOverlayVisible: params.visibleOverlayVisible, + mainWindow: params.mainWindow, + ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window), + }), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), +}); +const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler(); +const enforceOverlayLayerOrder = (0, overlay_1.createEnforceOverlayLayerOrderHandler)(enforceOverlayLayerOrderMainDeps); +async function loadYomitanExtension() { + const extension = await yomitanExtensionRuntime.loadYomitanExtension(); + if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) { + await syncYomitanDefaultProfileAnkiServer(); + } + return extension; +} +async function ensureYomitanExtensionLoaded() { + const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); + if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) { + await syncYomitanDefaultProfileAnkiServer(); + } + return extension; +} +let lastSyncedYomitanAnkiServer = null; +function getPreferredYomitanAnkiServerUrl() { + return (0, yomitan_anki_server_1.getPreferredYomitanAnkiServerUrl)(getResolvedConfig().ankiConnect); +} +function getYomitanParserRuntimeDeps() { + return { + getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + appState.yomitanParserWindow = window; + }, + getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + appState.yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + appState.yomitanParserInitPromise = promise; + }, + }; +} +async function syncYomitanDefaultProfileAnkiServer() { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + return; + } + const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); + if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) { + return; + } + const synced = await (0, services_1.syncYomitanDefaultAnkiServer)(targetUrl, getYomitanParserRuntimeDeps(), { + error: (message, ...args) => { + logger.error(message, ...args); + }, + info: (message, ...args) => { + logger.info(message, ...args); + }, + }, { + forceOverride: (0, yomitan_anki_server_1.shouldForceOverrideYomitanAnkiServer)(getResolvedConfig().ankiConnect), + }); + if (synced) { + lastSyncedYomitanAnkiServer = targetUrl; + } +} +function createModalWindow() { + const existingWindow = overlayManager.getModalWindow(); + if (existingWindow && !existingWindow.isDestroyed()) { + return existingWindow; + } + const window = createModalWindowHandler(); + overlayManager.setModalWindowBounds(getCurrentOverlayGeometry()); + return window; +} +function createMainWindow() { + const window = createMainWindowHandler(); + if (process.platform === 'win32') { + const overlayHwnd = getWindowsNativeWindowHandleNumber(window); + if (!(0, windows_helper_1.ensureWindowsOverlayTransparency)(overlayHwnd)) { + logger.warn('Failed to eagerly extend Windows overlay transparency via koffi'); + } + } + return window; +} +function ensureTray() { + ensureTrayHandler(); +} +function destroyTray() { + destroyTrayHandler(); +} +function initializeOverlayRuntime() { + initializeOverlayRuntimeHandler(); + appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); + appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAfterKnownWordUpdate); + syncOverlayMpvSubtitleSuppression(); +} +function openYomitanSettings() { + if (yomitanProfilePolicy.isExternalReadOnlyMode()) { + const message = 'Yomitan settings unavailable while using read-only external-profile mode.'; + logger.warn('Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.'); + (0, utils_2.showDesktopNotification)('SubMiner', { body: message }); + showMpvOsd(message); + return false; + } + openYomitanSettingsHandler(); + return true; +} +const { getConfiguredShortcuts, registerGlobalShortcuts, refreshGlobalAndOverlayShortcuts, cancelPendingMultiCopy, startPendingMultiCopy, cancelPendingMineSentenceMultiple, startPendingMineSentenceMultiple, syncOverlayShortcuts, refreshOverlayShortcuts, } = (0, composers_1.composeShortcutRuntimes)({ + globalShortcuts: { + getConfiguredShortcutsMainDeps: { + getResolvedConfig: () => getResolvedConfig(), + defaultConfig: config_2.DEFAULT_CONFIG, + resolveConfiguredShortcuts: utils_2.resolveConfiguredShortcuts, + }, + buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({ + getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), + registerGlobalShortcutsCore: services_1.registerGlobalShortcuts, + toggleVisibleOverlay: () => toggleVisibleOverlay(), + openYomitanSettings: () => openYomitanSettings(), + isDev, + getMainWindow: () => overlayManager.getMainWindow(), + }), + buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({ + unregisterAllGlobalShortcuts: () => electron_1.globalShortcut.unregisterAll(), + registerGlobalShortcuts: () => registerGlobalShortcutsHandler(), + syncOverlayShortcuts: () => syncOverlayShortcuts(), + }), + }, + numericShortcutRuntimeMainDeps: { + globalShortcut: electron_1.globalShortcut, + showMpvOsd: (text) => showMpvOsd(text), + setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), + clearTimer: (timer) => clearTimeout(timer), + }, + numericSessions: { + onMultiCopyDigit: (count) => handleMultiCopyDigit(count), + onMineSentenceDigit: (count) => handleMineSentenceDigit(count), + }, + overlayShortcutsRuntimeMainDeps: { + overlayShortcutsRuntime, + }, +}); +function resolveSessionBindingPlatform() { + if (process.platform === 'darwin') + return 'darwin'; + if (process.platform === 'win32') + return 'win32'; + return 'linux'; +} +function compileCurrentSessionBindings() { + return (0, session_bindings_1.compileSessionBindings)({ + keybindings: appState.keybindings, + shortcuts: getConfiguredShortcuts(), + statsToggleKey: getResolvedConfig().stats.toggleKey, + platform: resolveSessionBindingPlatform(), + rawConfig: getResolvedConfig(), + }); +} +function persistSessionBindings(bindings, warnings = []) { + const artifact = (0, session_bindings_1.buildPluginSessionBindingsArtifact)({ + bindings, + warnings, + numericSelectionTimeoutMs: getConfiguredShortcuts().multiCopyTimeoutMs, + }); + (0, session_bindings_artifact_1.writeSessionBindingsArtifact)(CONFIG_DIR, artifact); + appState.sessionBindings = bindings; + appState.sessionBindingsInitialized = true; + if (appState.mpvClient?.connected) { + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, [ + 'script-message', + 'subminer-reload-session-bindings', + ]); + } +} +function refreshCurrentSessionBindings() { + const compiled = compileCurrentSessionBindings(); + for (const warning of compiled.warnings) { + logger.warn(`[session-bindings] ${warning.message}`); + } + persistSessionBindings(compiled.bindings, compiled.warnings); +} +const { flushMpvLog, showMpvOsd } = (0, mpv_1.createMpvOsdRuntimeHandlers)({ + appendToMpvLogMainDeps: { + logPath: DEFAULT_MPV_LOG_PATH, + dirname: (targetPath) => path.dirname(targetPath), + mkdir: async (targetPath, options) => { + await fs.promises.mkdir(targetPath, options); + }, + appendFile: async (targetPath, data, options) => { + await fs.promises.appendFile(targetPath, data, options); + }, + now: () => new Date(), + }, + buildShowMpvOsdMainDeps: (appendToMpvLogHandler) => ({ + appendToMpvLog: (message) => appendToMpvLogHandler(message), + showMpvOsdRuntime: (mpvClient, text, fallbackLog) => (0, services_1.showMpvOsdRuntime)(mpvClient, text, fallbackLog), + getMpvClient: () => appState.mpvClient, + logInfo: (line) => logger.info(line), + }), +}); +flushPendingMpvLogWrites = () => { + void flushMpvLog(); +}; +const updateStateStore = (0, update_service_1.createFileUpdateStateStore)(path.join(USER_DATA_PATH, 'update-state.json')); +let updateService = null; +const electronNetFetch = (0, fetch_adapter_1.createElectronNetFetch)({ + fetch: (url, init) => electron_1.net.fetch(url, init), +}); +function getFetchForUpdater() { + return electronNetFetch; +} +async function updateLauncherFromSelectedRelease(launcherPath, channel = getResolvedConfig().updates.channel, release = null) { + const fetchForUpdater = getFetchForUpdater(); + if (!release) { + return { status: 'missing-asset', message: `No ${channel} GitHub release found.` }; + } + const sumsAsset = (0, release_assets_1.findReleaseAsset)(release, 'SHA256SUMS.txt'); + if (!sumsAsset) { + return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' }; + } + const sums = (0, release_assets_1.parseSha256Sums)(await (0, release_assets_1.fetchReleaseAssetText)(fetchForUpdater, sumsAsset.browser_download_url)); + const launcherResult = await (0, launcher_updater_1.updateLauncherFromRelease)({ + release, + sha256Sums: sums, + launcherPath, + downloadAsset: (url) => (0, release_assets_1.fetchReleaseAssetBuffer)(fetchForUpdater, url), + }); + const supportResults = await (0, support_assets_1.updateSupportAssetsFromRelease)({ + release, + sha256Sums: sums, + downloadAsset: (url) => (0, release_assets_1.fetchReleaseAssetBuffer)(fetchForUpdater, url), + }); + for (const result of supportResults) { + if (result.status === 'protected' && result.command) { + logger.warn(`Rofi theme update requires manual command: ${result.command}`); + } + else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') { + logger.warn(`Rofi theme update skipped: ${result.message ?? result.status}`); + } + } + return launcherResult; +} +function getUpdateService() { + if (updateService) + return updateService; + const appUpdater = (0, app_updater_1.createElectronAppUpdater)({ + currentVersion: electron_1.app.getVersion(), + isPackaged: electron_1.app.isPackaged, + log: (message) => logger.info(message), + getChannel: () => getResolvedConfig().updates.channel, + isNativeUpdaterSupported: () => (0, app_updater_1.isNativeUpdaterSupported)({ + platform: process.platform, + isPackaged: electron_1.app.isPackaged, + execPath: process.execPath, + env: process.env, + log: (message) => logger.warn(message), + }), + }); + const updateDialogPresenter = (0, update_dialogs_1.createUpdateDialogPresenter)({ + platform: process.platform, + focusApp: () => electron_1.app.focus({ steal: true }), + showMessageBox: (options) => electron_1.dialog.showMessageBox(options), + }); + updateService = (0, update_service_1.createUpdateService)({ + getConfig: () => getResolvedConfig().updates, + getCurrentVersion: () => electron_1.app.getVersion(), + now: () => Date.now(), + readState: () => updateStateStore.readState(), + writeState: (state) => updateStateStore.writeState(state), + checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel), + shouldFetchReleaseMetadata: () => process.platform !== 'darwin', + fetchLatestStableRelease: (channel) => (0, release_assets_1.fetchLatestStableRelease)({ fetch: getFetchForUpdater(), channel }), + updateLauncher: (launcherPath, channel, release) => updateLauncherFromSelectedRelease(launcherPath, channel, release), + showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version), + showUpdateAvailableDialog: (version) => updateDialogPresenter.showUpdateAvailableDialog(version), + showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message), + showManualUpdateRequiredDialog: (version) => updateDialogPresenter.showManualUpdateRequiredDialog(version), + downloadAppUpdate: () => appUpdater.downloadUpdate(), + showRestartDialog: () => updateDialogPresenter.showRestartDialog(), + quitAndInstall: () => appUpdater.quitAndInstall(), + notifyUpdateAvailable: (version) => (0, update_notifications_1.notifyUpdateAvailable)({ notificationType: getResolvedConfig().updates.notificationType, version }, { + showSystemNotification: (title, body) => (0, utils_2.showDesktopNotification)(title, { body }), + showOsdNotification: (message) => showMpvOsd(message), + log: (message) => logger.warn(message), + }), + log: (message) => logger.warn(message), + }); + return updateService; +} +const cycleSecondarySubMode = (0, mpv_1.createCycleSecondarySubModeRuntimeHandler)({ + cycleSecondarySubModeMainDeps: { + getSecondarySubMode: () => appState.secondarySubMode, + setSecondarySubMode: (mode) => { + setSecondarySubMode(mode); + }, + getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs, + setLastSecondarySubToggleAtMs: (timestampMs) => { + appState.lastSecondarySubToggleAtMs = timestampMs; + }, + broadcastToOverlayWindows: (channel, mode) => { + broadcastToOverlayWindows(channel, mode); + }, + showMpvOsd: (text) => showMpvOsd(text), + }, + cycleSecondarySubMode: (deps) => (0, services_1.cycleSecondarySubMode)(deps), +}); +function setSecondarySubMode(mode) { + appState.secondarySubMode = mode; +} +function handleCycleSecondarySubMode() { + cycleSecondarySubMode(); +} +function toggleSubtitleSidebar() { + broadcastToOverlayWindows(contracts_1.IPC_CHANNELS.event.subtitleSidebarToggle); +} +function togglePrimarySubtitleBar() { + broadcastToOverlayWindows(contracts_1.IPC_CHANNELS.event.primarySubtitleBarToggle); +} +async function triggerSubsyncFromConfig() { + await subsyncRuntime.triggerFromConfig(); +} +function handleMultiCopyDigit(count) { + handleMultiCopyDigitHandler(count); +} +function copyCurrentSubtitle() { + copyCurrentSubtitleHandler(); +} +const buildUpdateLastCardFromClipboardMainDepsHandler = (0, mining_1.createBuildUpdateLastCardFromClipboardMainDepsHandler)({ + getAnkiIntegration: () => appState.ankiIntegration, + readClipboardText: () => electron_1.clipboard.readText(), + showMpvOsd: (text) => showMpvOsd(text), + updateLastCardFromClipboardCore: services_1.updateLastCardFromClipboard, +}); +const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler(); +const updateLastCardFromClipboardHandler = (0, mining_1.createUpdateLastCardFromClipboardHandler)(updateLastCardFromClipboardMainDeps); +const buildRefreshKnownWordCacheMainDepsHandler = (0, mining_1.createBuildRefreshKnownWordCacheMainDepsHandler)({ + getAnkiIntegration: () => appState.ankiIntegration, + missingIntegrationMessage: 'AnkiConnect integration not enabled', +}); +const refreshKnownWordCacheMainDeps = buildRefreshKnownWordCacheMainDepsHandler(); +const refreshKnownWordCacheHandler = (0, mining_1.createRefreshKnownWordCacheHandler)(refreshKnownWordCacheMainDeps); +const buildTriggerFieldGroupingMainDepsHandler = (0, mining_1.createBuildTriggerFieldGroupingMainDepsHandler)({ + getAnkiIntegration: () => appState.ankiIntegration, + showMpvOsd: (text) => showMpvOsd(text), + triggerFieldGroupingCore: services_1.triggerFieldGrouping, +}); +const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler(); +const triggerFieldGroupingHandler = (0, mining_1.createTriggerFieldGroupingHandler)(triggerFieldGroupingMainDeps); +const buildMarkLastCardAsAudioCardMainDepsHandler = (0, mining_1.createBuildMarkLastCardAsAudioCardMainDepsHandler)({ + getAnkiIntegration: () => appState.ankiIntegration, + showMpvOsd: (text) => showMpvOsd(text), + markLastCardAsAudioCardCore: services_1.markLastCardAsAudioCard, +}); +const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler(); +const markLastCardAsAudioCardHandler = (0, mining_1.createMarkLastCardAsAudioCardHandler)(markLastCardAsAudioCardMainDeps); +const buildMineSentenceCardMainDepsHandler = (0, mining_1.createBuildMineSentenceCardMainDepsHandler)({ + getAnkiIntegration: () => appState.ankiIntegration, + getMpvClient: () => appState.mpvClient, + showMpvOsd: (text) => showMpvOsd(text), + mineSentenceCardCore: services_1.mineSentenceCard, + recordCardsMined: (count, noteIds) => { + ensureImmersionTrackerStarted(); + appState.immersionTracker?.recordCardsMined(count, noteIds); + }, +}); +const mineSentenceCardHandler = (0, mining_1.createMineSentenceCardHandler)(buildMineSentenceCardMainDepsHandler()); +const buildHandleMultiCopyDigitMainDepsHandler = (0, mining_1.createBuildHandleMultiCopyDigitMainDepsHandler)({ + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + writeClipboardText: (text) => electron_1.clipboard.writeText(text), + showMpvOsd: (text) => showMpvOsd(text), + handleMultiCopyDigitCore: services_1.handleMultiCopyDigit, +}); +const handleMultiCopyDigitMainDeps = buildHandleMultiCopyDigitMainDepsHandler(); +const handleMultiCopyDigitHandler = (0, mining_1.createHandleMultiCopyDigitHandler)(handleMultiCopyDigitMainDeps); +const buildCopyCurrentSubtitleMainDepsHandler = (0, mining_1.createBuildCopyCurrentSubtitleMainDepsHandler)({ + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + writeClipboardText: (text) => electron_1.clipboard.writeText(text), + showMpvOsd: (text) => showMpvOsd(text), + copyCurrentSubtitleCore: services_1.copyCurrentSubtitle, +}); +const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler(); +const copyCurrentSubtitleHandler = (0, mining_1.createCopyCurrentSubtitleHandler)(copyCurrentSubtitleMainDeps); +const buildHandleMineSentenceDigitMainDepsHandler = (0, mining_1.createBuildHandleMineSentenceDigitMainDepsHandler)({ + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getAnkiIntegration: () => appState.ankiIntegration, + getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, + showMpvOsd: (text) => showMpvOsd(text), + logError: (message, err) => { + logger.error(message, err); + }, + onCardsMined: (cards) => { + ensureImmersionTrackerStarted(); + appState.immersionTracker?.recordCardsMined(cards); + }, + handleMineSentenceDigitCore: services_1.handleMineSentenceDigit, +}); +const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler(); +const handleMineSentenceDigitHandler = (0, mining_1.createHandleMineSentenceDigitHandler)(handleMineSentenceDigitMainDeps); +const { setVisibleOverlayVisible: setVisibleOverlayVisibleHandler, toggleVisibleOverlay: toggleVisibleOverlayHandler, setOverlayVisible: setOverlayVisibleHandler, } = (0, overlay_1.createOverlayVisibilityRuntime)({ + setVisibleOverlayVisibleDeps: { + setVisibleOverlayVisibleCore: services_1.setVisibleOverlayVisible, + setVisibleOverlayVisibleState: (nextVisible) => { + overlayManager.setVisibleOverlayVisible(nextVisible); + }, + updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + }, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), +}); +const buildHandleOverlayModalClosedMainDepsHandler = (0, overlay_1.createBuildHandleOverlayModalClosedMainDepsHandler)({ + handleOverlayModalClosedRuntime: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), +}); +const handleOverlayModalClosedMainDeps = buildHandleOverlayModalClosedMainDepsHandler(); +const handleOverlayModalClosedHandler = (0, overlay_1.createHandleOverlayModalClosedHandler)(handleOverlayModalClosedMainDeps); +const buildAppendClipboardVideoToQueueMainDepsHandler = (0, overlay_1.createBuildAppendClipboardVideoToQueueMainDepsHandler)({ + appendClipboardVideoToQueueRuntime: startup_1.appendClipboardVideoToQueueRuntime, + getMpvClient: () => appState.mpvClient, + readClipboardText: () => electron_1.clipboard.readText(), + showMpvOsd: (text) => showMpvOsd(text), + sendMpvCommand: (command) => { + (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, command); + }, +}); +const appendClipboardVideoToQueueMainDeps = buildAppendClipboardVideoToQueueMainDepsHandler(); +const appendClipboardVideoToQueueHandler = (0, overlay_1.createAppendClipboardVideoToQueueHandler)(appendClipboardVideoToQueueMainDeps); +async function loadSubtitleSourceText(source) { + if (/^https?:\/\//i.test(source)) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 4000); + try { + const response = await fetch(source, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Failed to download subtitle source (${response.status})`); + } + return await response.text(); + } + finally { + clearTimeout(timeoutId); + } + } + const filePath = (0, subtitle_prefetch_source_1.resolveSubtitleSourcePath)(source); + return fs.promises.readFile(filePath, 'utf8'); +} +function parseTrackId(value) { + if (typeof value === 'number' && Number.isInteger(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value.trim()); + return Number.isInteger(parsed) ? parsed : null; + } + return null; +} +function buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath) { + return [ + '-hide_banner', + '-nostdin', + '-y', + '-loglevel', + 'error', + '-an', + '-vn', + '-i', + videoPath, + '-map', + `0:${ffIndex}`, + '-f', + path.extname(outputPath).slice(1), + outputPath, + ]; +} +async function extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track) { + const ffIndex = parseTrackId(track['ff-index']); + const codec = typeof track.codec === 'string' ? track.codec : null; + const extension = (0, utils_3.codecToExtension)(codec ?? undefined); + if (ffIndex === null || extension === null) { + return null; + } + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-')); + const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`); + try { + await new Promise((resolve, reject) => { + const child = (0, node_child_process_1.spawn)(ffmpegPath, buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath)); + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', (error) => { + reject(error); + }); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`)); + }); + }); + } + catch (error) { + await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + throw error; + } + return { + path: outputPath, + cleanup: async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }, + }; +} +const shiftSubtitleDelayToAdjacentCueHandler = (0, services_1.createShiftSubtitleDelayToAdjacentCueHandler)({ + getMpvClient: () => appState.mpvClient, + loadSubtitleSourceText, + sendMpvCommand: (command) => (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, command), + showMpvOsd: (text) => showMpvOsd(text), +}); +async function dispatchSessionAction(request) { + await (0, session_actions_1.dispatchSessionAction)(request, { + toggleStatsOverlay: () => (0, stats_window_js_2.toggleStatsOverlay)({ + staticDir: statsDistPath, + preloadPath: statsPreloadPath, + getApiBaseUrl: () => ensureStatsServerStarted().url, + getToggleKey: () => getResolvedConfig().stats.toggleKey, + resolveBounds: () => getCurrentOverlayGeometry(), + onVisibilityChanged: (visible) => { + appState.statsOverlayVisible = visible; + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, + }), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + copyCurrentSubtitle: () => copyCurrentSubtitle(), + copySubtitleCount: (count) => handleMultiCopyDigit(count), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + mineSentenceCard: () => mineSentenceCard(), + mineSentenceCount: (count) => handleMineSentenceDigit(count), + toggleSecondarySub: () => handleCycleSecondarySubMode(), + toggleSubtitleSidebar: () => toggleSubtitleSidebar(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + openJimaku: () => openJimakuOverlay(), + openSessionHelp: () => openSessionHelpOverlay(), + openCharacterDictionary: () => openCharacterDictionaryOverlay(), + openControllerSelect: () => openControllerSelectOverlay(), + openControllerDebug: () => openControllerDebugOverlay(), + openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), + openPlaylistBrowser: () => openPlaylistBrowser(), + replayCurrentSubtitle: () => (0, services_1.replayCurrentSubtitleRuntime)(appState.mpvClient), + playNextSubtitle: () => (0, services_1.playNextSubtitleRuntime)(appState.mpvClient), + shiftSubDelayToAdjacentSubtitle: (direction) => shiftSubtitleDelayToAdjacentCueHandler(direction), + cycleRuntimeOption: (id, direction) => { + if (!appState.runtimeOptionsManager) { + return { ok: false, error: 'Runtime options manager unavailable' }; + } + return (0, runtime_options_ipc_1.applyRuntimeOptionResultRuntime)(appState.runtimeOptionsManager.cycleOption(id, direction), (text) => showMpvOsd(text)); + }, + showMpvOsd: (text) => showMpvOsd(text), + }); +} +const { playlistBrowserMainDeps } = (0, playlist_browser_ipc_1.createPlaylistBrowserIpcRuntime)(() => appState.mpvClient, { + getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, + getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, +}); +const { registerIpcRuntimeHandlers } = (0, composers_1.composeIpcRuntimeHandlers)({ + mpvCommandMainDeps: { + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + openJimaku: () => openJimakuOverlay(), + openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), + openPlaylistBrowser: () => openPlaylistBrowser(), + cycleRuntimeOption: (id, direction) => { + if (!appState.runtimeOptionsManager) { + return { ok: false, error: 'Runtime options manager unavailable' }; + } + return (0, runtime_options_ipc_1.applyRuntimeOptionResultRuntime)(appState.runtimeOptionsManager.cycleOption(id, direction), (text) => showMpvOsd(text)); + }, + showMpvOsd: (text) => showMpvOsd(text), + replayCurrentSubtitle: () => (0, services_1.replayCurrentSubtitleRuntime)(appState.mpvClient), + playNextSubtitle: () => (0, services_1.playNextSubtitleRuntime)(appState.mpvClient), + shiftSubDelayToAdjacentSubtitle: (direction) => shiftSubtitleDelayToAdjacentCueHandler(direction), + sendMpvCommand: (rawCommand) => (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, rawCommand), + getMpvClient: () => appState.mpvClient, + isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), + hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, + }, + handleMpvCommandFromIpcRuntime: ipc_mpv_command_1.handleMpvCommandFromIpcRuntime, + runSubsyncManualFromIpc: (request) => subsyncRuntime.runManualFromIpc(request), + registration: { + runtimeOptions: { + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + showMpvOsd: (text) => showMpvOsd(text), + }, + mainDeps: { + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), + focusMainWindow: () => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) + return; + if (!mainWindow.isFocused()) { + mainWindow.focus(); + } + }, + onOverlayModalClosed: (modal, senderWindow) => { + const modalWindow = overlayManager.getModalWindow(); + if (senderWindow && + modalWindow && + senderWindow === modalWindow && + !senderWindow.isDestroyed()) { + senderWindow.setIgnoreMouseEvents(true, { forward: true }); + senderWindow.hide(); + } + handleOverlayModalClosed(modal); + }, + onOverlayModalOpened: (modal) => { + overlayModalRuntime.notifyOverlayModalOpened(modal); + }, + onOverlayMouseInteractionChanged: (active, senderWindow) => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || senderWindow !== mainWindow) { + return; + } + if (visibleOverlayInteractionActive === active) { + if (active && process.platform === 'darwin' && !mainWindow.isFocused()) { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + } + return; + } + visibleOverlayInteractionActive = active; + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, + onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), + openYomitanSettings: () => openYomitanSettings(), + quitApp: () => requestAppQuit(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)), + getCurrentSubtitleRaw: () => appState.currentSubText, + getCurrentSubtitleAss: () => appState.currentSubAssText, + getSubtitleSidebarSnapshot: async () => { + const currentSubtitle = { + text: appState.currentSubText, + startTime: appState.mpvClient?.currentSubStart ?? null, + endTime: appState.mpvClient?.currentSubEnd ?? null, + }; + const currentTimeSec = appState.mpvClient?.currentTimePos ?? null; + const config = getResolvedConfig().subtitleSidebar; + const client = appState.mpvClient; + if (!client?.connected) { + return { + cues: appState.activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } + try { + const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] = await Promise.all([ + client.requestProperty('current-tracks/sub/external-filename').catch(() => null), + client.requestProperty('current-tracks/sub').catch(() => null), + client.requestProperty('track-list'), + client.requestProperty('sid'), + client.requestProperty('path'), + ]); + const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; + if (!videoPath) { + return { + cues: appState.activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } + const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({ + currentExternalFilenameRaw, + currentTrackRaw, + trackListRaw, + sidRaw, + videoPath, + }); + if (!resolvedSource) { + return { + cues: appState.activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } + try { + if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) { + return { + cues: appState.activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } + const content = await loadSubtitleSourceText(resolvedSource.path); + const cues = (0, subtitle_cue_parser_1.parseSubtitleCues)(content, resolvedSource.path); + appState.activeParsedSubtitleCues = cues; + appState.activeParsedSubtitleSource = resolvedSource.sourceKey; + return { + cues, + currentTimeSec, + currentSubtitle, + config, + }; + } + finally { + await resolvedSource.cleanup?.(); + } + } + catch { + return { + cues: appState.activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } + }, + getPlaybackPaused: () => appState.playbackPaused, + getSubtitlePosition: () => loadSubtitlePosition(), + getSubtitleStyle: () => { + const resolvedConfig = getResolvedConfig(); + return (0, overlay_1.resolveSubtitleStyleForRenderer)(resolvedConfig); + }, + saveSubtitlePosition: (position) => saveSubtitlePosition(position), + getMecabTokenizer: () => appState.mecabTokenizer, + getKeybindings: () => appState.keybindings, + getSessionBindings: () => appState.sessionBindings, + getConfiguredShortcuts: () => getConfiguredShortcuts(), + dispatchSessionAction: (request) => dispatchSessionAction(request), + getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, + getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey, + getControllerConfig: () => getResolvedConfig().controller, + saveControllerConfig: (update) => { + const currentRawConfig = configService.getRawConfig(); + configService.patchRawConfig({ + controller: (0, controller_config_update_js_1.applyControllerConfigUpdate)(currentRawConfig.controller, update), + }); + }, + saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => { + configService.patchRawConfig({ + controller: { + preferredGamepadId, + preferredGamepadLabel, + }, + }); + }, + getSecondarySubMode: () => appState.secondarySubMode, + getMpvClient: () => appState.mpvClient, + getAnkiConnectStatus: () => appState.ankiIntegration !== null, + getRuntimeOptions: () => getRuntimeOptionsState(), + reportOverlayContentBounds: (payload) => { + overlayContentMeasurementStore.report(payload); + }, + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), + openAnilistSetup: () => openAnilistSetupWindow(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), + retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), + runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }), + getCharacterDictionarySelection: () => characterDictionaryRuntime.getManualSelectionSnapshot(), + setCharacterDictionarySelection: async (mediaId) => (0, character_dictionary_selection_1.applyCharacterDictionarySelection)({ mediaId }, { + setManualSelection: (request) => characterDictionaryRuntime.setManualSelection(request), + resetAnilistMediaGuessState, + runSyncNow: () => characterDictionaryAutoSyncRuntime.runSyncNow(), + warn: (message, error) => logger.warn(message, error), + }), + appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), + ...playlistBrowserMainDeps, + getImmersionTracker: () => appState.immersionTracker, + }, + ankiJimakuDeps: (0, dependencies_1.createAnkiJimakuIpcRuntimeServiceDeps)({ + patchAnkiConnectEnabled: (enabled) => { + configService.patchRawConfig({ ankiConnect: { enabled } }); + }, + getResolvedConfig: () => getResolvedConfig(), + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getMpvClient: () => appState.mpvClient, + getAnkiIntegration: () => appState.ankiIntegration, + setAnkiIntegration: (integration) => { + appState.ankiIntegration = integration; + appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); + appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAfterKnownWordUpdate); + }, + getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + showDesktopNotification: utils_2.showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback(), + broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), + getFieldGroupingResolver: () => getFieldGroupingResolver(), + setFieldGroupingResolver: (resolver) => setFieldGroupingResolver(resolver), + parseMediaInfo: (mediaPath) => (0, utils_1.parseMediaInfo)(mediaRuntime.resolveMediaPathForJimaku(mediaPath)), + getCurrentMediaPath: () => appState.currentMediaPath, + jimakuFetchJson: (endpoint, query) => configDerivedRuntime.jimakuFetchJson(endpoint, query), + getJimakuMaxEntryResults: () => configDerivedRuntime.getJimakuMaxEntryResults(), + getJimakuLanguagePreference: () => configDerivedRuntime.getJimakuLanguagePreference(), + resolveJimakuApiKey: () => configDerivedRuntime.resolveJimakuApiKey(), + isRemoteMediaPath: (mediaPath) => (0, utils_1.isRemoteMediaPath)(mediaPath), + downloadToFile: (url, destPath, headers) => (0, utils_1.downloadToFile)(url, destPath, headers), + }), + registerIpcRuntimeServices: ipc_runtime_1.registerIpcRuntimeServices, + }, +}); +const { handleCliCommand, handleInitialArgs } = (0, composers_1.composeCliStartupHandlers)({ + cliCommandContextMainDeps: { + appState, + setLogLevel: (level) => (0, logger_1.setLogLevel)(level, 'cli'), + texthookerService, + getResolvedConfig: () => getResolvedConfig(), + defaultWebsocketPort: config_2.DEFAULT_CONFIG.websocket.port, + defaultAnnotationWebsocketPort: config_2.DEFAULT_CONFIG.annotationWebsocket.port, + hasMpvWebsocketPlugin: () => (0, services_1.hasMpvWebsocketPlugin)(), + openExternal: (url) => electron_1.shell.openExternal(url), + logBrowserOpenError: (url, error) => logger.error(`Failed to open browser for texthooker URL: ${url}`, error), + showMpvOsd: (text) => showMpvOsd(text), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(), + openFirstRunSetupWindow: (force) => openFirstRunSetupWindow(force), + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + copyCurrentSubtitle: () => copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs) => startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs) => startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + refreshKnownWordCache: () => refreshKnownWordCache(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), + openAnilistSetupWindow: () => openAnilistSetupWindow(), + openJellyfinSetupWindow: () => openJellyfinSetupWindow(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), + processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), + generateCharacterDictionary: async (targetPath) => { + const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason(); + if (disabledReason) { + throw new Error(disabledReason); + } + return await characterDictionaryRuntime.generateForCurrentMedia(targetPath); + }, + getCharacterDictionarySelection: async (targetPath) => characterDictionaryRuntime.getManualSelectionSnapshot(targetPath), + setCharacterDictionarySelection: async (request) => (0, character_dictionary_selection_1.applyCharacterDictionarySelection)(request, { + setManualSelection: (selectionRequest) => characterDictionaryRuntime.setManualSelection(selectionRequest), + resetAnilistMediaGuessState, + runSyncNow: () => characterDictionaryAutoSyncRuntime.runSyncNow(), + warn: (message, error) => logger.warn(message, error), + }), + runJellyfinCommand: (argsFromCommand) => runJellyfinCommand(argsFromCommand), + runStatsCommand: (argsFromCommand, source) => runStatsCliCommand(argsFromCommand, source), + runUpdateCommand: async (argsFromCommand, source) => { + await (0, update_cli_command_1.runUpdateCliCommand)(argsFromCommand, source, { + checkForUpdates: (request) => getUpdateService().checkForUpdates(request), + writeResponse: (responsePath, payload) => (0, update_cli_command_1.writeUpdateCliCommandResponse)(responsePath, payload), + logWarn: (message, error) => logger.warn(message, error), + }); + }, + runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request), + openYomitanSettings: () => openYomitanSettings(), + openConfigSettingsWindow: () => openConfigSettingsWindow(), + cycleSecondarySubMode: () => handleCycleSecondarySubMode(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + printHelp: () => (0, help_1.printHelp)(DEFAULT_TEXTHOOKER_PORT), + stopApp: () => requestAppQuit(), + hasMainWindow: () => Boolean(overlayManager.getMainWindow()), + dispatchSessionAction: (request) => dispatchSessionAction(request), + getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, + schedule: (fn, delayMs) => setTimeout(fn, delayMs), + logInfo: (message) => logger.info(message), + logDebug: (message) => logger.debug(message), + logWarn: (message) => logger.warn(message), + logError: (message, err) => logger.error(message, err), + }, + cliCommandRuntimeHandlerMainDeps: { + handleTexthookerOnlyModeTransitionMainDeps: { + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + setTexthookerOnlyMode: (enabled) => { + appState.texthookerOnlyMode = enabled; + }, + commandNeedsOverlayStartupPrereqs: (inputArgs) => (0, args_1.commandNeedsOverlayStartupPrereqs)(inputArgs), + startBackgroundWarmups: () => startBackgroundWarmups(), + logInfo: (message) => logger.info(message), + }, + handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) => (0, cli_runtime_1.handleCliCommandRuntimeServiceWithContext)(args, source, cliContext), + }, + initialArgsRuntimeHandlerMainDeps: { + getInitialArgs: () => appState.initialArgs, + isBackgroundMode: () => appState.backgroundMode, + shouldEnsureTrayOnStartup: () => (0, startup_tray_policy_1.shouldEnsureTrayOnStartupForInitialArgs)(process.platform, appState.initialArgs), + shouldRunHeadlessInitialCommand: (args) => (0, args_1.isHeadlessInitialCommand)(args), + ensureTray: () => ensureTray(), + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + hasImmersionTracker: () => Boolean(appState.immersionTracker), + getMpvClient: () => appState.mpvClient, + commandNeedsOverlayStartupPrereqs: (args) => (0, args_1.commandNeedsOverlayStartupPrereqs)(args), + commandNeedsOverlayRuntime: (args) => (0, args_1.commandNeedsOverlayRuntime)(args), + ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + initializeOverlayRuntime: () => initializeOverlayRuntime(), + logInfo: (message) => logger.info(message), + }, +}); +const { runAndApplyStartupState } = (0, composers_1.composeHeadlessStartupHandlers)({ + startupRuntimeHandlersDeps: { + appLifecycleRuntimeRunnerMainDeps: { + app: appLifecycleApp, + platform: process.platform, + shouldStartApp: (nextArgs) => (0, args_1.shouldStartApp)(nextArgs), + parseArgs: (argv) => (0, args_1.parseArgs)(argv), + handleCliCommand: (nextArgs, source) => handleCliCommand(nextArgs, source), + printHelp: () => (0, help_1.printHelp)(DEFAULT_TEXTHOOKER_PORT), + logNoRunningInstance: () => appLogger.logNoRunningInstance(), + onReady: appReadyRuntimeRunner, + onWillQuitCleanup: () => onWillQuitCleanupHandler(), + shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), + restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), + shouldQuitOnWindowAllClosed: () => (0, startup_tray_policy_1.shouldQuitOnWindowAllClosedForTrayState)({ + backgroundMode: appState.backgroundMode, + hasTray: Boolean(appTray), + }), + }, + createAppLifecycleRuntimeRunner: (params) => (0, startup_lifecycle_1.createAppLifecycleRuntimeRunner)(params), + buildStartupBootstrapMainDeps: (startAppLifecycle) => ({ + argv: process.argv, + parseArgs: (argv) => (0, args_1.parseArgs)(argv), + setLogLevel: (level, source) => { + (0, logger_1.setLogLevel)(level, source); + }, + forceX11Backend: (args) => { + (0, utils_2.forceX11Backend)(args); + }, + enforceUnsupportedWaylandMode: (args) => { + (0, utils_2.enforceUnsupportedWaylandMode)(args); + }, + shouldStartApp: (args) => (0, args_1.shouldStartApp)(args), + getDefaultSocketPath: () => getDefaultSocketPath(), + defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, + configDir: CONFIG_DIR, + defaultConfig: config_2.DEFAULT_CONFIG, + generateConfigTemplate: (config) => (0, config_2.generateConfigTemplate)(config), + generateDefaultConfigFile: (args, options) => (0, utils_2.generateDefaultConfigFile)(args, options), + setExitCode: (code) => { + process.exitCode = code; + }, + quitApp: () => requestAppQuit(), + logGenerateConfigError: (message) => logger.error(message), + startAppLifecycle, + }), + createStartupBootstrapRuntimeDeps: (deps) => (0, startup_2.createStartupBootstrapRuntimeDeps)(deps), + runStartupBootstrapRuntime: services_1.runStartupBootstrapRuntime, + applyStartupState: (startupState) => (0, state_1.applyStartupState)(appState, startupState), + }, +}); +runAndApplyStartupState(); +void electron_1.app.whenReady().then(() => { + if (!(0, startup_mode_flags_1.shouldStartAutomaticUpdateChecks)(appState.initialArgs)) { + return; + } + getUpdateService().startAutomaticChecks(); +}); +const startupModeFlags = (0, startup_mode_flags_1.getStartupModeFlags)(appState.initialArgs); +const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup; +const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup; +if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartup)) { + if ((0, anilist_1.isAnilistTrackingEnabled)(getResolvedConfig())) { + void refreshAnilistClientSecretStateIfEnabled({ + force: true, + allowSetupPrompt: false, + }).catch((error) => { + logger.error('Failed to refresh AniList client secret state during startup', error); + }); + anilistStateRuntime.refreshRetryQueueState(); + } + void initializeDiscordPresenceService().catch((error) => { + logger.error('Failed to initialize Discord presence service during startup', error); + }); +} +const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } = (0, overlay_window_runtime_handlers_1.createOverlayWindowRuntimeHandlers)({ + createOverlayWindowDeps: { + createOverlayWindowCore: (kind, options) => (0, services_1.createOverlayWindow)(kind, options), + isDev, + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), + setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), + isOverlayVisible: (windowKind) => windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false, + getYomitanSession: () => appState.yomitanSession, + tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), + forwardTabToMpv: () => (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, ['keypress', 'TAB']), + onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), + onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + onWindowClosed: (windowKind) => { + if (windowKind === 'visible') { + cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); + overlayManager.setMainWindow(null); + } + else { + overlayManager.setModalWindow(null); + } + }, + }, + setMainWindow: (window) => overlayManager.setMainWindow(window), + setModalWindow: (window) => overlayManager.setModalWindow(window), +}); +function refreshTrayMenuIfPresent() { + if (appTray) { + ensureTrayHandler(); + } +} +function getJellyfinTrayDiscoveryDeps() { + return { + getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), + getRemoteSession: () => appState.jellyfinRemoteSession, + clearStoredSession: () => jellyfinTokenStore.clearSession(), + stopRemoteSession: () => stopJellyfinRemoteSession(), + startRemoteSession: (options) => startJellyfinRemoteSession(options), + refreshTrayMenu: () => refreshTrayMenuIfPresent(), + logger, + showMpvOsd: (message) => showMpvOsd(message), + }; +} +const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = (0, overlay_1.createTrayRuntimeHandlers)({ + resolveTrayIconPathDeps: { + resolveTrayIconPathRuntime: overlay_1.resolveTrayIconPathRuntime, + platform: process.platform, + resourcesPath: process.resourcesPath, + appPath: electron_1.app.getAppPath(), + dirname: __dirname, + joinPath: (...parts) => path.join(...parts), + fileExists: (candidate) => fs.existsSync(candidate), + }, + buildTrayMenuTemplateDeps: { + buildTrayMenuTemplateRuntime: overlay_1.buildTrayMenuTemplateRuntime, + initializeOverlayRuntime: () => initializeOverlayRuntime(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + openSessionHelpModal: () => openSessionHelpOverlay(), + openTexthookerInBrowser: () => handleCliCommand((0, args_1.parseArgs)(['--texthooker', '--open-browser'])), + showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false, + showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(), + openFirstRunSetupWindow: () => openFirstRunSetupWindow(), + showWindowsMpvLauncherSetup: () => process.platform === 'win32', + openYomitanSettings: () => openYomitanSettings(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + openConfigSettingsWindow: () => openConfigSettingsWindow(), + openJellyfinSetupWindow: () => openJellyfinSetupWindow(), + isJellyfinConfigured: () => (0, jellyfin_tray_discovery_1.isJellyfinConfiguredForTray)(getJellyfinTrayDiscoveryDeps()), + isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession), + toggleJellyfinDiscovery: () => (0, jellyfin_tray_discovery_1.toggleJellyfinDiscoveryFromTray)(getJellyfinTrayDiscoveryDeps()), + openAnilistSetupWindow: () => openAnilistSetupWindow(), + checkForUpdates: () => { + void getUpdateService().checkForUpdates({ source: 'manual' }); + }, + quitApp: () => requestAppQuit(), + }, + ensureTrayDeps: { + getTray: () => appTray, + setTray: (tray) => { + appTray = tray; + }, + createImageFromPath: (iconPath) => electron_1.nativeImage.createFromPath(iconPath), + createEmptyImage: () => electron_1.nativeImage.createEmpty(), + createTray: (icon) => new electron_1.Tray(icon), + trayTooltip: TRAY_TOOLTIP, + platform: process.platform, + logWarn: (message) => logger.warn(message), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + }, + destroyTrayDeps: { + getTray: () => appTray, + setTray: (tray) => { + appTray = tray; + }, + }, + buildMenuFromTemplate: (template) => electron_1.Menu.buildFromTemplate(template), +}); +const yomitanProfilePolicy = (0, yomitan_profile_policy_1.createYomitanProfilePolicy)({ + externalProfilePath: getResolvedConfig().yomitan.externalProfilePath, + logInfo: (message) => logger.info(message), +}); +const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath; +const yomitanExtensionRuntime = (0, overlay_1.createYomitanExtensionRuntime)({ + loadYomitanExtensionCore: services_1.loadYomitanExtension, + userDataPath: USER_DATA_PATH, + externalProfilePath: configuredExternalYomitanProfilePath, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + appState.yomitanParserWindow = window; + }, + setYomitanParserReadyPromise: (promise) => { + appState.yomitanParserReadyPromise = promise; + }, + setYomitanParserInitPromise: (promise) => { + appState.yomitanParserInitPromise = promise; + }, + setYomitanExtension: (extension) => { + appState.yomitanExt = extension; + }, + setYomitanSession: (nextSession) => { + appState.yomitanSession = nextSession; + }, + getYomitanExtension: () => appState.yomitanExt, + getLoadInFlight: () => yomitanLoadInFlight, + setLoadInFlight: (promise) => { + yomitanLoadInFlight = promise; + }, +}); +const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({ + initializeOverlayRuntimeMainDeps: { + appState, + overlayManager: { + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + }, + refreshCurrentSubtitle: () => { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), + }, + createMainWindow: () => { + if (appState.initialArgs && (0, args_1.isHeadlessInitialCommand)(appState.initialArgs)) { + return; + } + createMainWindow(); + }, + registerGlobalShortcuts: () => { + if (appState.initialArgs && (0, args_1.isHeadlessInitialCommand)(appState.initialArgs)) { + return; + } + registerGlobalShortcuts(); + }, + createWindowTracker: (override, targetMpvSocketPath) => { + if (appState.initialArgs && (0, args_1.isHeadlessInitialCommand)(appState.initialArgs)) { + return null; + } + return (0, window_trackers_1.createWindowTracker)(override, targetMpvSocketPath); + }, + updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry), + bindOverlayOwner: () => { + const mainWindow = overlayManager.getMainWindow(); + if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) + return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath); + if (targetWindowHwnd !== null && + (0, windows_helper_1.bindWindowsOverlayAboveMpv)(overlayHwnd, targetWindowHwnd)) { + return; + } + const tracker = appState.windowTracker; + const mpvResult = tracker + ? (() => { + try { + const win32 = require('./window-trackers/win32'); + const poll = win32.findMpvWindows(); + const focused = poll.matches.find((m) => m.isForeground); + return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null; + } + catch { + return null; + } + })() + : null; + if (!mpvResult) + return; + if (!(0, windows_helper_1.setWindowsOverlayOwner)(overlayHwnd, mpvResult.hwnd)) { + logger.warn('Failed to set overlay owner via koffi'); + } + }, + releaseOverlayOwner: () => { + const mainWindow = overlayManager.getMainWindow(); + if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) + return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + if (!(0, windows_helper_1.clearWindowsOverlayOwner)(overlayHwnd)) { + logger.warn('Failed to clear overlay owner via koffi'); + } + }, + getOverlayWindows: () => getOverlayWindows(), + getResolvedConfig: () => getResolvedConfig(), + showDesktopNotification: utils_2.showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback(), + getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + shouldStartAnkiIntegration: () => !(appState.initialArgs && (0, args_1.isHeadlessInitialCommand)(appState.initialArgs)), + }, + initializeOverlayRuntimeBootstrapDeps: { + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + initializeOverlayRuntimeCore: services_1.initializeOverlayRuntime, + setOverlayRuntimeInitialized: (initialized) => { + appState.overlayRuntimeInitialized = initialized; + }, + startBackgroundWarmups: () => { + if (appState.initialArgs && (0, args_1.isHeadlessInitialCommand)(appState.initialArgs)) { + return; + } + startBackgroundWarmups(); + }, + }, +}); +const { openYomitanSettings: openYomitanSettingsHandler } = (0, overlay_1.createYomitanSettingsRuntime)({ + ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), + getYomitanExtension: () => appState.yomitanExt, + getYomitanExtensionLoadInFlight: () => yomitanLoadInFlight, + getYomitanSession: () => appState.yomitanSession, + openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => { + (0, services_1.openYomitanSettingsWindow)({ + yomitanExt: yomitanExt, + getExistingWindow: () => getExistingWindow(), + setWindow: (window) => setWindow(window), + yomitanSession: yomitanSession ?? appState.yomitanSession, + onWindowClosed: () => { + if (appState.yomitanParserWindow) { + (0, services_1.clearYomitanParserCachesForWindow)(appState.yomitanParserWindow); + } + }, + }); + }, + getExistingWindow: () => appState.yomitanSettingsWindow, + setWindow: (window) => { + appState.yomitanSettingsWindow = window; + }, + logWarn: (message) => logger.warn(message), + logError: (message, error) => logger.error(message, error), +}); +async function updateLastCardFromClipboard() { + await updateLastCardFromClipboardHandler(); +} +async function refreshKnownWordCache() { + await refreshKnownWordCacheHandler(); +} +async function triggerFieldGrouping() { + await triggerFieldGroupingHandler(); +} +async function markLastCardAsAudioCard() { + await markLastCardAsAudioCardHandler(); +} +async function mineSentenceCard() { + await mineSentenceCardHandler(); +} +function handleMineSentenceDigit(count) { + handleMineSentenceDigitHandler(count); +} +function ensureOverlayWindowsReadyForVisibilityActions() { + if (!appState.overlayRuntimeInitialized) { + initializeOverlayRuntime(); + return; + } + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + createMainWindow(); + } +} +function setVisibleOverlayVisible(visible) { + ensureOverlayWindowsReadyForVisibilityActions(); + if (!visible) { + cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); + } + if (visible) { + void ensureOverlayMpvSubtitlesHidden(); + } + setVisibleOverlayVisibleHandler(visible); + syncOverlayMpvSubtitleSuppression(); +} +function toggleVisibleOverlay() { + ensureOverlayWindowsReadyForVisibilityActions(); + if (overlayManager.getVisibleOverlayVisible()) { + cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); + } + else { + void ensureOverlayMpvSubtitlesHidden(); + } + toggleVisibleOverlayHandler(); + syncOverlayMpvSubtitleSuppression(); +} +function setOverlayVisible(visible) { + if (!visible) { + cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); + } + if (visible) { + void ensureOverlayMpvSubtitlesHidden(); + } + setOverlayVisibleHandler(visible); + syncOverlayMpvSubtitleSuppression(); +} +function handleOverlayModalClosed(modal) { + handleOverlayModalClosedHandler(modal); +} +function appendClipboardVideoToQueue() { + return appendClipboardVideoToQueueHandler(); +} +registerIpcRuntimeHandlers(); +//# sourceMappingURL=main.js.map \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..55c1eb2c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4133 @@ +{ + "name": "subminer", + "version": "0.15.0-beta.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "subminer", + "version": "0.15.0-beta.3", + "license": "GPL-3.0-or-later", + "dependencies": { + "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/geist-mono": "^5.2.7", + "@xhayper/discord-rpc": "^1.3.3", + "axios": "^1.13.5", + "commander": "^14.0.3", + "electron-updater": "^6.8.3", + "hono": "^4.12.7", + "jsonc-parser": "^3.3.1", + "koffi": "^2.15.6", + "libsql": "^0.5.22", + "vscode-json-languageservice": "^5.7.2", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^25.3.0", + "@types/ws": "^8.18.1", + "electron": "39.8.6", + "electron-builder": "26.8.2", + "esbuild": "^0.25.12", + "prettier": "^3.8.1", + "typescript": "^5.9.3" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@discordjs/collection": { + "version": "2.1.1", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@electron/get/node_modules/fs-extra/node_modules/jsonfile": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/get/node_modules/fs-extra/node_modules/universalify": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "got": "^11.7.0", + "graceful-fs": "^4.2.11", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^11.2.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^7.5.6", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/rebuild/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.4", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.2.8", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/geist-mono": { + "version": "5.2.7", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.28", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "license": "MIT" + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "license": "MIT" + }, + "node_modules/@xhayper/discord-rpc": { + "version": "1.3.3", + "license": "ISC", + "dependencies": { + "@discordjs/rest": "^2.6.1", + "@vladfrangu/async_event_emitter": "^2.4.7", + "discord-api-types": "^0.38.42", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.12", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.12", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.8.1", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.8.2", + "electron-builder-squirrel-windows": "26.8.2" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra/node_modules/jsonfile": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra/node_modules/universalify": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/axios": { + "version": "1.13.6", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.5.1", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "10.5.0", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone-response/node_modules/mimic-response": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/crc": { + "version": "3.8.0", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/which/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.0.2", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/discord-api-types": { + "version": "0.38.43", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/dmg-builder": { + "version": "26.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.2", + "builder-util": "26.8.1", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "39.8.6", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "26.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.2", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.8.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.8.2", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.8.2", + "builder-util": "26.8.1", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-updater": { + "version": "6.8.3", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra/node_modules/jsonfile": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra/node_modules/universalify": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.19.15", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/electron/node_modules/@types/node/node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.8", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/koffi": { + "version": "2.15.6", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "license": "MIT" + }, + "node_modules/libsql": { + "version": "0.5.28", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.28", + "@libsql/darwin-x64": "0.5.28", + "@libsql/linux-arm-gnueabihf": "0.5.28", + "@libsql/linux-arm-musleabihf": "0.5.28", + "@libsql/linux-arm64-gnu": "0.5.28", + "@libsql/linux-arm64-musl": "0.5.28", + "@libsql/linux-x64-gnu": "0.5.28", + "@libsql/linux-x64-musl": "0.5.28", + "@libsql/win32-x64-msvc": "0.5.28" + } + }, + "node_modules/lodash": { + "version": "4.18.0", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "license": "MIT" + }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.3", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/minipass/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "4.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "11.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nopt": { + "version": "8.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/pe-library": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssri": { + "version": "12.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.11", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.24.1", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/vscode-json-languageservice": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.2.tgz", + "integrity": "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "jsonc-parser": "^3.3.1", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index b47fedf4..e8ce491c 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,9 @@ "build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer", "build:stats": "cd stats && bun run build", "dev:stats": "cd stats && bun run dev", - "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets", + "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:launcher && bun run build:assets", "build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap", + "build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap", "changelog:build": "bun run scripts/build-changelog.ts build-release", "changelog:check": "bun run scripts/build-changelog.ts check", "changelog:docs": "bun run scripts/build-changelog.ts docs", @@ -48,7 +49,7 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts", + "test:core:src": "bun test src/preload-settings.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", @@ -116,6 +117,7 @@ "jsonc-parser": "^3.3.1", "koffi": "^2.15.6", "libsql": "^0.5.22", + "vscode-json-languageservice": "^5.7.2", "ws": "^8.19.0" }, "devDependencies": { diff --git a/release/prerelease-notes.md b/release/prerelease-notes.md index f8b081d1..9800017b 100644 --- a/release/prerelease-notes.md +++ b/release/prerelease-notes.md @@ -3,33 +3,33 @@ ## Highlights ### Added -**Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`. Checks include checksum verification, configurable notifications, and an opt-in channel for prerelease builds. The `subminer` launcher and Linux rofi theme are also updated automatically. +- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`. Checks include checksum verification, configurable notifications, and an opt-in channel for prerelease builds. The `subminer` launcher and Linux rofi theme are also updated automatically. -**First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH. +- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH. ### Fixed -**macOS Overlay:** Significantly improved overlay focus and stability — the overlay now hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is also fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused. +- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay now hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is also fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused. -**Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing. +- **Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing. -**Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults. +- **Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults. -**AniList Progress:** Progress threshold checks now use fresh playback position data, so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status. +- **AniList Progress:** Progress threshold checks now use fresh playback position data, so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status. -**Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. +- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. -**Updater — Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists. +- **Updater - Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists. -**Updater — macOS:** Update dialogs now come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native updater path without triggering premature Squirrel install checks. +- **Updater - macOS:** Update dialogs now come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native updater path without triggering premature Squirrel install checks. -**Setup — macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, returning control to the terminal. +- **Setup - macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, returning control to the terminal. -**Launcher — Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently. +- **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently. -**Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running. +- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running. -**Build — Linux Install:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation. +- **Build - Linux Install:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation. ## Installation diff --git a/scripts/prepare-build-assets.mjs b/scripts/prepare-build-assets.mjs index 575a2869..c5a3901e 100644 --- a/scripts/prepare-build-assets.mjs +++ b/scripts/prepare-build-assets.mjs @@ -7,6 +7,8 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); const rendererSourceDir = path.join(repoRoot, 'src', 'renderer'); const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer'); +const settingsSourceDir = path.join(repoRoot, 'src', 'settings'); +const settingsOutputDir = path.join(repoRoot, 'dist', 'settings'); const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts'); const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift'); const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos'); @@ -21,14 +23,22 @@ function copyFile(sourcePath, outputPath) { fs.copyFileSync(sourcePath, outputPath); } -function copyRendererAssets() { - copyFile(path.join(rendererSourceDir, 'index.html'), path.join(rendererOutputDir, 'index.html')); - copyFile(path.join(rendererSourceDir, 'style.css'), path.join(rendererOutputDir, 'style.css')); - fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(rendererOutputDir, 'fonts'), { +function copyAssets(sourceDir, outputDir, label) { + copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html')); + copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css')); + fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), { recursive: true, force: true, }); - process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`); + process.stdout.write(`Staged ${label} assets in ${outputDir}\n`); +} + +function copyRendererAssets() { + copyAssets(rendererSourceDir, rendererOutputDir, 'renderer'); +} + +function copySettingsAssets() { + copyAssets(settingsSourceDir, settingsOutputDir, 'settings'); } function fallbackToMacosSource() { @@ -70,6 +80,7 @@ function buildMacosHelper() { function main() { copyRendererAssets(); + copySettingsAssets(); buildMacosHelper(); } diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index d703015a..90749a4e 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -212,6 +212,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(shouldStartApp(settings), true); assert.equal(shouldRunSettingsOnlyStartup(settings), true); + const configSettings = parseArgs(['--config']); + assert.equal(configSettings.configSettings, true); + assert.equal(hasExplicitCommand(configSettings), true); + assert.equal(shouldStartApp(configSettings), true); + assert.equal(shouldRunSettingsOnlyStartup(configSettings), false); + assert.equal(commandNeedsOverlayRuntime(configSettings), false); + assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false); + const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']); assert.equal(settingsWithOverlay.settings, true); assert.equal(settingsWithOverlay.toggleVisibleOverlay, true); diff --git a/src/cli/args.ts b/src/cli/args.ts index 3a6937c9..a78b9795 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -11,6 +11,7 @@ export interface CliArgs { toggleVisibleOverlay: boolean; togglePrimarySubtitleBar: boolean; settings: boolean; + configSettings: boolean; setup: boolean; show: boolean; hide: boolean; @@ -115,6 +116,7 @@ export function parseArgs(argv: string[]): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, settings: false, + configSettings: false, setup: false, show: false, hide: false, @@ -234,6 +236,7 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true; else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true; else if (arg === '--settings' || arg === '--yomitan') args.settings = true; + else if (arg === '--config') args.configSettings = true; else if (arg === '--setup') args.setup = true; else if (arg === '--show') args.show = true; else if (arg === '--hide') args.hide = true; @@ -486,6 +489,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.toggleVisibleOverlay || args.togglePrimarySubtitleBar || args.settings || + args.configSettings || args.setup || args.show || args.hide || @@ -558,6 +562,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.toggleVisibleOverlay && !args.togglePrimarySubtitleBar && !args.settings && + !args.configSettings && !args.setup && !args.show && !args.hide && @@ -625,6 +630,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.toggleVisibleOverlay || args.togglePrimarySubtitleBar || args.settings || + args.configSettings || args.setup || args.copySubtitle || args.copySubtitleMultiple || @@ -679,6 +685,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.toggle && !args.toggleVisibleOverlay && !args.togglePrimarySubtitleBar && + !args.configSettings && !args.show && !args.hide && !args.setup && diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts index 2fdcffa4..8a875343 100644 --- a/src/cli/help.test.ts +++ b/src/cli/help.test.ts @@ -22,6 +22,7 @@ test('printHelp includes configured texthooker port', () => { assert.match(output, /--open-browser\s+Open texthooker in your default browser/); assert.doesNotMatch(output, /--refresh-known-words/); assert.match(output, /--setup\s+Open first-run setup window/); + assert.match(output, /--config\s+Open configuration window/); assert.match(output, /--anilist-status/); assert.match(output, /--anilist-retry-queue/); assert.match(output, /--dictionary/); diff --git a/src/cli/help.ts b/src/cli/help.ts index 954c33fa..83e5a477 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -25,6 +25,7 @@ ${B}Overlay${R} --show-visible-overlay Show subtitle overlay --hide-visible-overlay Hide subtitle overlay --settings Open Yomitan settings window + --config Open configuration window --setup Open first-run setup window --auto-start-overlay Auto-hide mpv subs, show overlay on connect diff --git a/src/config/config.test.ts b/src/config/config.test.ts index d1d29bb1..fbdcf27e 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2324,7 +2324,10 @@ test('template generator includes known keys', () => { /"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/, ); assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./); - assert.match(output, /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/); + assert.match( + output, + /"openBrowser": false,? \/\/ Open the texthooker page in the default browser when the server starts\. Values: true \| false/, + ); assert.match( output, /"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/, diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index 0f8e1ade..6178d1c9 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { ResolvedConfig } from '../../types/config'; import { CONFIG_OPTION_REGISTRY, CONFIG_TEMPLATE_SECTIONS, @@ -13,6 +14,77 @@ import { buildImmersionConfigOptionRegistry } from './options-immersion'; import { buildIntegrationConfigOptionRegistry } from './options-integrations'; import { buildSubtitleConfigOptionRegistry } from './options-subtitle'; +function collectConfigLeafPaths(config: ResolvedConfig): string[] { + const leaves: string[] = []; + const visit = (value: unknown, prefix: string): void => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + leaves.push(prefix); + return; + } + const entries = Object.entries(value as Record); + if (entries.length === 0) { + leaves.push(prefix); + return; + } + for (const [key, child] of entries) { + visit(child, prefix ? `${prefix}.${key}` : key); + } + }; + visit(config, ''); + return leaves; +} + +// DEFAULT_CONFIG leaves that intentionally do not have a curated +// CONFIG_OPTION_REGISTRY entry. The generated config.example.jsonc still +// includes these paths, but their inline comments fall back to an auto- +// humanized key name instead of a written description. +// +// Current intentional gaps: +// - subtitleStyle.*: thin wrappers around standard CSS properties; the +// CSS reference is the canonical documentation surface. +// - keybindings: an array of {key, command} objects, documented at the +// section level via CONFIG_TEMPLATE_SECTIONS rather than per-leaf. +// +// New leaves added to DEFAULT_CONFIG should prefer a registry entry over +// an allowlist entry. Only allowlist a path when the registry is genuinely +// the wrong surface for it. +const UNDOCUMENTED_LEAVES: ReadonlySet = new Set([ + 'keybindings', + 'subtitleStyle.backdropFilter', + 'subtitleStyle.backgroundColor', + 'subtitleStyle.fontColor', + 'subtitleStyle.fontFamily', + 'subtitleStyle.fontKerning', + 'subtitleStyle.fontSize', + 'subtitleStyle.fontStyle', + 'subtitleStyle.fontWeight', + 'subtitleStyle.jlptColors.N1', + 'subtitleStyle.jlptColors.N2', + 'subtitleStyle.jlptColors.N3', + 'subtitleStyle.jlptColors.N4', + 'subtitleStyle.jlptColors.N5', + 'subtitleStyle.knownWordColor', + 'subtitleStyle.letterSpacing', + 'subtitleStyle.lineHeight', + 'subtitleStyle.nPlusOneColor', + 'subtitleStyle.secondary.backdropFilter', + 'subtitleStyle.secondary.backgroundColor', + 'subtitleStyle.secondary.fontColor', + 'subtitleStyle.secondary.fontFamily', + 'subtitleStyle.secondary.fontKerning', + 'subtitleStyle.secondary.fontSize', + 'subtitleStyle.secondary.fontStyle', + 'subtitleStyle.secondary.fontWeight', + 'subtitleStyle.secondary.letterSpacing', + 'subtitleStyle.secondary.lineHeight', + 'subtitleStyle.secondary.textRendering', + 'subtitleStyle.secondary.textShadow', + 'subtitleStyle.secondary.wordSpacing', + 'subtitleStyle.textRendering', + 'subtitleStyle.textShadow', + 'subtitleStyle.wordSpacing', +]); + test('config option registry includes critical paths and has unique entries', () => { const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path); @@ -40,6 +112,35 @@ test('config option registry includes critical paths and has unique entries', () assert.equal(new Set(paths).size, paths.length); }); +test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => { + const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path)); + const leaves = collectConfigLeafPaths(DEFAULT_CONFIG); + + const missing = leaves + .filter((path) => !registryPaths.has(path) && !UNDOCUMENTED_LEAVES.has(path)) + .sort(); + assert.deepEqual( + missing, + [], + `Add CONFIG_OPTION_REGISTRY entries (preferred) or add to UNDOCUMENTED_LEAVES allowlist: ${missing.join(', ')}`, + ); + + const stale = [...UNDOCUMENTED_LEAVES].filter((path) => registryPaths.has(path)).sort(); + assert.deepEqual( + stale, + [], + `Remove from UNDOCUMENTED_LEAVES (now covered by CONFIG_OPTION_REGISTRY): ${stale.join(', ')}`, + ); + + const leafSet = new Set(leaves); + const orphaned = [...UNDOCUMENTED_LEAVES].filter((path) => !leafSet.has(path)).sort(); + assert.deepEqual( + orphaned, + [], + `Remove from UNDOCUMENTED_LEAVES (no longer a DEFAULT_CONFIG leaf): ${orphaned.join(', ')}`, + ); +}); + test('config template sections include expected domains and unique keys', () => { const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key); const requiredKeys: (typeof keys)[number][] = [ diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 53d6a4c8..a5454379 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -322,6 +322,46 @@ export function buildCoreConfigOptionRegistry( defaultValue: defaultConfig.texthooker.launchAtStartup, description: 'Launch texthooker server automatically when SubMiner starts.', }, + { + path: 'texthooker.openBrowser', + kind: 'boolean', + defaultValue: defaultConfig.texthooker.openBrowser, + description: 'Open the texthooker page in the default browser when the server starts.', + }, + { + path: 'subtitlePosition.yPercent', + kind: 'number', + defaultValue: defaultConfig.subtitlePosition.yPercent, + description: + 'Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.', + }, + { + path: 'auto_start_overlay', + kind: 'boolean', + defaultValue: defaultConfig.auto_start_overlay, + description: 'Auto-start the subtitle overlay window when SubMiner launches.', + }, + { + path: 'secondarySub.secondarySubLanguages', + kind: 'array', + defaultValue: defaultConfig.secondarySub.secondarySubLanguages, + description: + 'Language code priority list used to auto-select a secondary subtitle track when available.', + }, + { + path: 'secondarySub.autoLoadSecondarySub', + kind: 'boolean', + defaultValue: defaultConfig.secondarySub.autoLoadSecondarySub, + description: + 'Automatically load a matching secondary subtitle when the primary subtitle loads.', + }, + { + path: 'secondarySub.defaultMode', + kind: 'enum', + enumValues: ['hidden', 'visible', 'hover'], + defaultValue: defaultConfig.secondarySub.defaultMode, + description: 'Default visibility mode for the secondary subtitle bar.', + }, { path: 'websocket.enabled', kind: 'enum', @@ -360,6 +400,27 @@ export function buildCoreConfigOptionRegistry( defaultValue: defaultConfig.subsync.replace, description: 'Replace the active subtitle file when sync completes.', }, + { + path: 'subsync.alass_path', + kind: 'string', + defaultValue: defaultConfig.subsync.alass_path, + description: + 'Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.', + }, + { + path: 'subsync.ffsubsync_path', + kind: 'string', + defaultValue: defaultConfig.subsync.ffsubsync_path, + description: + 'Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.', + }, + { + path: 'subsync.ffmpeg_path', + kind: 'string', + defaultValue: defaultConfig.subsync.ffmpeg_path, + description: + 'Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.', + }, { path: 'startupWarmups.lowPowerMode', kind: 'boolean', @@ -422,5 +483,112 @@ export function buildCoreConfigOptionRegistry( defaultValue: defaultConfig.shortcuts.multiCopyTimeoutMs, description: 'Timeout for multi-copy/mine modes.', }, + { + path: 'shortcuts.toggleVisibleOverlayGlobal', + kind: 'string', + defaultValue: defaultConfig.shortcuts.toggleVisibleOverlayGlobal, + description: + 'Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.', + }, + { + path: 'shortcuts.copySubtitle', + kind: 'string', + defaultValue: defaultConfig.shortcuts.copySubtitle, + description: 'Accelerator that copies the current subtitle line to the clipboard.', + }, + { + path: 'shortcuts.copySubtitleMultiple', + kind: 'string', + defaultValue: defaultConfig.shortcuts.copySubtitleMultiple, + description: + 'Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.', + }, + { + path: 'shortcuts.updateLastCardFromClipboard', + kind: 'string', + defaultValue: defaultConfig.shortcuts.updateLastCardFromClipboard, + description: + 'Accelerator that updates the last mined Anki card using the current clipboard contents.', + }, + { + path: 'shortcuts.triggerFieldGrouping', + kind: 'string', + defaultValue: defaultConfig.shortcuts.triggerFieldGrouping, + description: 'Accelerator that triggers Kiku field grouping on duplicate cards.', + }, + { + path: 'shortcuts.triggerSubsync', + kind: 'string', + defaultValue: defaultConfig.shortcuts.triggerSubsync, + description: 'Accelerator that triggers subsync against the active subtitle file.', + }, + { + path: 'shortcuts.mineSentence', + kind: 'string', + defaultValue: defaultConfig.shortcuts.mineSentence, + description: 'Accelerator that mines the current sentence as a new Anki card.', + }, + { + path: 'shortcuts.mineSentenceMultiple', + kind: 'string', + defaultValue: defaultConfig.shortcuts.mineSentenceMultiple, + description: + 'Accelerator that mines consecutive sentences while the multi-mine window stays open.', + }, + { + path: 'shortcuts.toggleSecondarySub', + kind: 'string', + defaultValue: defaultConfig.shortcuts.toggleSecondarySub, + description: 'Accelerator that toggles the secondary subtitle bar visibility.', + }, + { + path: 'shortcuts.markAudioCard', + kind: 'string', + defaultValue: defaultConfig.shortcuts.markAudioCard, + description: 'Accelerator that marks the last mined card as an audio card.', + }, + { + path: 'shortcuts.openCharacterDictionary', + kind: 'string', + defaultValue: defaultConfig.shortcuts.openCharacterDictionary, + description: 'Accelerator that opens the character dictionary modal.', + }, + { + path: 'shortcuts.openRuntimeOptions', + kind: 'string', + defaultValue: defaultConfig.shortcuts.openRuntimeOptions, + description: 'Accelerator that opens the runtime options modal.', + }, + { + path: 'shortcuts.openJimaku', + kind: 'string', + defaultValue: defaultConfig.shortcuts.openJimaku, + description: 'Accelerator that opens the Jimaku subtitle search modal.', + }, + { + path: 'shortcuts.openSessionHelp', + kind: 'string', + defaultValue: defaultConfig.shortcuts.openSessionHelp, + description: 'Accelerator that opens the session help / keybinding cheatsheet.', + }, + { + path: 'shortcuts.openControllerSelect', + kind: 'string', + defaultValue: defaultConfig.shortcuts.openControllerSelect, + description: 'Accelerator that opens the controller selection and learn-mode modal.', + }, + { + path: 'shortcuts.openControllerDebug', + kind: 'string', + defaultValue: defaultConfig.shortcuts.openControllerDebug, + description: + 'Accelerator that opens the controller debug modal with live axis/button readouts.', + }, + { + path: 'shortcuts.toggleSubtitleSidebar', + kind: 'string', + defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar, + description: 'Accelerator that toggles the subtitle sidebar visibility.', + }, ]; } diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 97975611..dcc84e7a 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -15,6 +15,12 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.ankiConnect.enabled, description: 'Enable AnkiConnect integration.', }, + { + path: 'ankiConnect.url', + kind: 'string', + defaultValue: defaultConfig.ankiConnect.url, + description: 'Base URL of the AnkiConnect HTTP server.', + }, { path: 'ankiConnect.pollingRate', kind: 'number', @@ -58,6 +64,37 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.ankiConnect.fields.word, description: 'Card field for the mined word or expression text.', }, + { + path: 'ankiConnect.fields.audio', + kind: 'string', + defaultValue: defaultConfig.ankiConnect.fields.audio, + description: 'Card field that receives generated sentence audio.', + }, + { + path: 'ankiConnect.fields.image', + kind: 'string', + defaultValue: defaultConfig.ankiConnect.fields.image, + description: 'Card field that receives the captured screenshot or animated image.', + }, + { + path: 'ankiConnect.fields.sentence', + kind: 'string', + defaultValue: defaultConfig.ankiConnect.fields.sentence, + description: 'Card field that receives the source sentence text.', + }, + { + path: 'ankiConnect.fields.miscInfo', + kind: 'string', + defaultValue: defaultConfig.ankiConnect.fields.miscInfo, + description: + 'Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).', + }, + { + path: 'ankiConnect.fields.translation', + kind: 'string', + defaultValue: defaultConfig.ankiConnect.fields.translation, + description: 'Card field that receives the current selection or translated text.', + }, { path: 'ankiConnect.ai.enabled', kind: 'boolean', @@ -83,6 +120,41 @@ export function buildIntegrationConfigOptionRegistry( description: 'Automatically update newly added cards.', runtime: runtimeOptionById.get('anki.autoUpdateNewCards'), }, + { + path: 'ankiConnect.behavior.overwriteAudio', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.behavior.overwriteAudio, + description: + 'When updating an existing card, overwrite the audio field instead of skipping it.', + }, + { + path: 'ankiConnect.behavior.overwriteImage', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.behavior.overwriteImage, + description: + 'When updating an existing card, overwrite the image field instead of skipping it.', + }, + { + path: 'ankiConnect.behavior.mediaInsertMode', + kind: 'enum', + enumValues: ['append', 'prepend'], + defaultValue: defaultConfig.ankiConnect.behavior.mediaInsertMode, + description: + 'Whether new media is appended after or prepended before existing field contents on update.', + }, + { + path: 'ankiConnect.behavior.highlightWord', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.behavior.highlightWord, + description: 'Bold the mined word inside the sentence field on the saved Anki card.', + }, + { + path: 'ankiConnect.behavior.notificationType', + kind: 'enum', + enumValues: ['osd', 'system', 'both', 'none'], + defaultValue: defaultConfig.ankiConnect.behavior.notificationType, + description: 'Notification surface used to announce mining and update outcomes.', + }, { path: 'ankiConnect.media.syncAnimatedImageToWordAudio', kind: 'boolean', @@ -90,6 +162,97 @@ export function buildIntegrationConfigOptionRegistry( description: 'For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio.', }, + { + path: 'ankiConnect.media.generateAudio', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.media.generateAudio, + description: 'Generate sentence audio for mined cards.', + }, + { + path: 'ankiConnect.media.generateImage', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.media.generateImage, + description: 'Generate screenshot or animated image for mined cards.', + }, + { + path: 'ankiConnect.media.imageType', + kind: 'enum', + enumValues: ['static', 'avif'], + defaultValue: defaultConfig.ankiConnect.media.imageType, + description: + 'Image capture type: "static" for a single still frame, "avif" for an animated AVIF.', + }, + { + path: 'ankiConnect.media.imageFormat', + kind: 'enum', + enumValues: ['jpg', 'png', 'webp'], + defaultValue: defaultConfig.ankiConnect.media.imageFormat, + description: 'Encoding format used when imageType is "static".', + }, + { + path: 'ankiConnect.media.imageQuality', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.media.imageQuality, + description: 'Quality (0-100) used for lossy static image encoders.', + }, + { + path: 'ankiConnect.media.imageMaxWidth', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.media.imageMaxWidth, + description: + 'Optional maximum width for static images. Leave unset to preserve the source resolution.', + }, + { + path: 'ankiConnect.media.imageMaxHeight', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.media.imageMaxHeight, + description: + 'Optional maximum height for static images. Leave unset to preserve the source resolution.', + }, + { + path: 'ankiConnect.media.animatedFps', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.media.animatedFps, + description: 'Target frame rate for animated AVIF captures.', + }, + { + path: 'ankiConnect.media.animatedMaxWidth', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.media.animatedMaxWidth, + description: 'Maximum width applied to animated AVIF captures.', + }, + { + path: 'ankiConnect.media.animatedMaxHeight', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.media.animatedMaxHeight, + description: + 'Optional maximum height for animated AVIF captures. Leave unset to preserve aspect ratio.', + }, + { + path: 'ankiConnect.media.animatedCrf', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.media.animatedCrf, + description: + 'Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.', + }, + { + path: 'ankiConnect.media.audioPadding', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.media.audioPadding, + description: 'Seconds of padding appended to both ends of generated sentence audio.', + }, + { + path: 'ankiConnect.media.fallbackDuration', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.media.fallbackDuration, + description: 'Fallback clip duration in seconds when subtitle timing data is unavailable.', + }, + { + path: 'ankiConnect.media.maxMediaDuration', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration, + description: 'Maximum allowed media clip duration in seconds.', + }, { path: 'ankiConnect.knownWords.matchMode', kind: 'enum', @@ -148,6 +311,44 @@ export function buildIntegrationConfigOptionRegistry( description: 'Kiku duplicate-card field grouping mode.', runtime: runtimeOptionById.get('anki.kikuFieldGrouping'), }, + { + path: 'ankiConnect.isKiku.enabled', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.isKiku.enabled, + description: 'Enable Kiku-specific mining behaviors (duplicate handling, field grouping).', + }, + { + path: 'ankiConnect.isKiku.deleteDuplicateInAuto', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.isKiku.deleteDuplicateInAuto, + description: + 'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.', + }, + { + path: 'ankiConnect.isLapis.enabled', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.isLapis.enabled, + description: 'Enable Lapis-specific mining behaviors and sentence card model targeting.', + }, + { + path: 'ankiConnect.isLapis.sentenceCardModel', + kind: 'string', + defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel, + description: 'Note type name used by Lapis sentence cards.', + }, + { + path: 'ankiConnect.metadata.pattern', + kind: 'string', + defaultValue: defaultConfig.ankiConnect.metadata.pattern, + description: + 'Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).', + }, + { + path: 'jimaku.apiBaseUrl', + kind: 'string', + defaultValue: defaultConfig.jimaku.apiBaseUrl, + description: 'Base URL of the Jimaku subtitle search API.', + }, { path: 'jimaku.languagePreference', kind: 'enum', @@ -277,6 +478,26 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.jellyfin.username, description: 'Default Jellyfin username used during CLI login.', }, + { + path: 'jellyfin.deviceId', + kind: 'string', + defaultValue: defaultConfig.jellyfin.deviceId, + description: + 'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.', + }, + { + path: 'jellyfin.clientName', + kind: 'string', + defaultValue: defaultConfig.jellyfin.clientName, + description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.', + }, + { + path: 'jellyfin.clientVersion', + kind: 'string', + defaultValue: defaultConfig.jellyfin.clientVersion, + description: + 'Client version sent on the Jellyfin authentication handshake; primarily internal.', + }, { path: 'jellyfin.defaultLibraryId', kind: 'string', @@ -387,6 +608,18 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.ai.baseUrl, description: 'Base URL for the shared OpenAI-compatible AI provider.', }, + { + path: 'ai.model', + kind: 'string', + defaultValue: defaultConfig.ai.model, + description: 'Default model identifier requested from the shared AI provider.', + }, + { + path: 'ai.systemPrompt', + kind: 'string', + defaultValue: defaultConfig.ai.systemPrompt, + description: 'Default system prompt sent with shared AI provider requests.', + }, { path: 'ai.requestTimeoutMs', kind: 'number', diff --git a/src/config/settings/jsonc-edit.test.ts b/src/config/settings/jsonc-edit.test.ts new file mode 100644 index 00000000..516a2468 --- /dev/null +++ b/src/config/settings/jsonc-edit.test.ts @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parse } from 'jsonc-parser'; +import { DEFAULT_CONFIG } from '../definitions'; +import { applyConfigSettingsPatchToContent, buildConfigSettingsSnapshot } from './jsonc-edit'; +import { buildConfigSettingsRegistry } from './registry'; + +test('applyConfigSettingsPatchToContent preserves JSONC comments while setting nested values', () => { + const input = `{ + // keep this comment + "subtitleStyle": { + "fontSize": 35, + }, +}`; + + const result = applyConfigSettingsPatchToContent({ + content: input, + operations: [ + { + op: 'set', + path: 'subtitleStyle.autoPauseVideoOnHover', + value: false, + }, + ], + previousWarnings: [], + }); + + assert.equal(result.ok, true); + assert.match(result.content, /keep this comment/); + const parsed = parse(result.content); + assert.equal(parsed.subtitleStyle.autoPauseVideoOnHover, false); + assert.equal(parsed.subtitleStyle.fontSize, 35); +}); + +test('applyConfigSettingsPatchToContent reset removes explicit path', () => { + const input = `{ + "subtitleStyle": { + "fontSize": 41, + "autoPauseVideoOnHover": false + } +}`; + + const result = applyConfigSettingsPatchToContent({ + content: input, + operations: [{ op: 'reset', path: 'subtitleStyle.autoPauseVideoOnHover' }], + previousWarnings: [], + }); + + assert.equal(result.ok, true); + const parsed = parse(result.content); + assert.equal(Object.hasOwn(parsed.subtitleStyle, 'autoPauseVideoOnHover'), false); + assert.equal(parsed.subtitleStyle.fontSize, 41); +}); + +test('applyConfigSettingsPatchToContent rejects warnings caused by modified fields', () => { + const result = applyConfigSettingsPatchToContent({ + content: '{}', + operations: [ + { + op: 'set', + path: 'subtitleStyle.autoPauseVideoOnHover', + value: 'bad', + }, + ], + previousWarnings: [], + }); + + assert.equal(result.ok, false); + assert.equal(result.warnings[0]?.path, 'subtitleStyle.autoPauseVideoOnHover'); +}); + +test('buildConfigSettingsSnapshot masks configured secret values', () => { + const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG); + const snapshot = buildConfigSettingsSnapshot({ + configPath: '/tmp/config.jsonc', + rawConfig: { + ai: { + apiKey: 'secret-key', + }, + }, + resolvedConfig: { + ...DEFAULT_CONFIG, + ai: { + ...DEFAULT_CONFIG.ai, + apiKey: 'secret-key', + }, + }, + warnings: [], + fields, + }); + + const apiKey = snapshot.values['ai.apiKey']; + assert.deepEqual(apiKey, { configured: true }); +}); diff --git a/src/config/settings/jsonc-edit.ts b/src/config/settings/jsonc-edit.ts new file mode 100644 index 00000000..08d91176 --- /dev/null +++ b/src/config/settings/jsonc-edit.ts @@ -0,0 +1,200 @@ +import { + applyEdits, + modify, + parse as parseJsonc, + type FormattingOptions, + type ParseError, +} from 'jsonc-parser'; +import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config'; +import type { + ConfigSettingsField, + ConfigSettingsPatchOperation, + ConfigSettingsSnapshot, +} from '../../types/settings'; +import { resolveConfig } from '../resolve'; +import { getConfigValueAtPath } from './registry'; + +const JSONC_FORMATTING_OPTIONS: FormattingOptions = { + insertSpaces: true, + tabSize: 2, + eol: '\n', +}; + +export type ConfigSettingsPatchApplyResult = + | { + ok: true; + content: string; + rawConfig: RawConfig; + resolvedConfig: ResolvedConfig; + warnings: ConfigValidationWarning[]; + } + | { + ok: false; + content: string; + warnings: ConfigValidationWarning[]; + error: string; + }; + +interface ApplyConfigSettingsPatchOptions { + content: string; + operations: ConfigSettingsPatchOperation[]; + previousWarnings: ConfigValidationWarning[]; +} + +interface BuildConfigSettingsSnapshotOptions { + configPath: string; + rawConfig: RawConfig; + resolvedConfig: ResolvedConfig; + warnings: ConfigValidationWarning[]; + fields: ConfigSettingsField[]; +} + +function pathToSegments(path: string): string[] { + return path.split('.').filter(Boolean); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function pathStartsWith(path: string, prefix: string): boolean { + return path === prefix || path.startsWith(`${prefix}.`); +} + +function warningBelongsToModifiedPath( + warning: ConfigValidationWarning, + operation: ConfigSettingsPatchOperation, +): boolean { + return ( + pathStartsWith(warning.path, operation.path) || pathStartsWith(operation.path, warning.path) + ); +} + +function warningIdentity(warning: ConfigValidationWarning): string { + return `${warning.path}\n${JSON.stringify(warning.value)}\n${warning.message}`; +} + +function parseRawConfig(content: string): RawConfig { + const errors: ParseError[] = []; + const parsed = parseJsonc(content || '{}', errors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (errors.length > 0) { + throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`); + } + return isRecord(parsed) ? (parsed as RawConfig) : {}; +} + +function normalizeContent(content: string): string { + return content.trim().length > 0 ? content : '{}\n'; +} + +function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string { + const edits = modify( + content, + pathToSegments(operation.path), + operation.op === 'reset' ? undefined : operation.value, + { + formattingOptions: JSONC_FORMATTING_OPTIONS, + getInsertionIndex: (properties) => properties.length, + }, + ); + return applyEdits(content, edits); +} + +function collectModifiedWarnings( + warnings: ConfigValidationWarning[], + operations: ConfigSettingsPatchOperation[], + previousWarnings: ConfigValidationWarning[], +): ConfigValidationWarning[] { + const previous = new Set(previousWarnings.map(warningIdentity)); + return warnings.filter((warning) => { + if (!operations.some((operation) => warningBelongsToModifiedPath(warning, operation))) { + return false; + } + return !previous.has(warningIdentity(warning)); + }); +} + +export function applyConfigSettingsPatchToContent( + options: ApplyConfigSettingsPatchOptions, +): ConfigSettingsPatchApplyResult { + let content = normalizeContent(options.content); + + try { + parseRawConfig(content); + } catch (error) { + return { + ok: false, + content, + warnings: [], + error: error instanceof Error ? error.message : 'Invalid JSONC.', + }; + } + + try { + for (const operation of options.operations) { + content = applySingleOperation(content, operation); + } + + const rawConfig = parseRawConfig(content); + const { resolved, warnings } = resolveConfig(rawConfig); + const modifiedWarnings = collectModifiedWarnings( + warnings, + options.operations, + options.previousWarnings, + ); + if (modifiedWarnings.length > 0) { + return { + ok: false, + content, + warnings: modifiedWarnings, + error: 'One or more modified settings failed validation.', + }; + } + + return { + ok: true, + content, + rawConfig, + resolvedConfig: resolved, + warnings, + }; + } catch (error) { + return { + ok: false, + content, + warnings: [], + error: error instanceof Error ? error.message : 'Failed to update config content.', + }; + } +} + +export function buildConfigSettingsSnapshot( + options: BuildConfigSettingsSnapshotOptions, +): ConfigSettingsSnapshot { + const values: Record = {}; + + for (const field of options.fields) { + const rawValue = getConfigValueAtPath(options.rawConfig, field.configPath); + const resolvedValue = getConfigValueAtPath(options.resolvedConfig, field.configPath); + if (field.secret) { + values[field.configPath] = { + configured: + (typeof rawValue === 'string' && rawValue.length > 0) || + (typeof resolvedValue === 'string' && resolvedValue.length > 0), + }; + continue; + } + + values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue); + } + + return { + configPath: options.configPath, + fields: options.fields, + values, + warnings: options.warnings, + }; +} diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts new file mode 100644 index 00000000..8a1399cf --- /dev/null +++ b/src/config/settings/registry.test.ts @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_CONFIG } from '../definitions'; +import { + buildConfigSettingsRegistry, + getConfigSettingsCoverage, + LEGACY_HIDDEN_CONFIG_PATHS, +} from './registry'; + +test('config settings registry places hover pause under viewing playback behavior', () => { + const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG); + const hoverPause = fields.find( + (field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover', + ); + + assert.ok(hoverPause); + assert.equal(hoverPause.category, 'viewing'); + assert.equal(hoverPause.section, 'Playback pause behavior'); + assert.equal(hoverPause.control, 'boolean'); +}); + +test('config settings registry hides legacy and ignored paths from normal fields', () => { + const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG); + const visiblePaths = new Set( + fields.filter((field) => !field.legacyHidden).map((field) => field.configPath), + ); + + for (const path of LEGACY_HIDDEN_CONFIG_PATHS) { + assert.equal(visiblePaths.has(path), false, path); + } + assert.equal(visiblePaths.has('controller.buttonIndices'), false); +}); + +test('config settings registry covers canonical defaults or marks explicit raw-only gaps', () => { + const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG); + const coverage = getConfigSettingsCoverage(DEFAULT_CONFIG, fields); + + assert.deepEqual(coverage.uncoveredDefaultPaths, []); +}); diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts new file mode 100644 index 00000000..1d1ea393 --- /dev/null +++ b/src/config/settings/registry.ts @@ -0,0 +1,346 @@ +import type { ResolvedConfig } from '../../types/config'; +import type { + ConfigSettingsCategory, + ConfigSettingsControl, + ConfigSettingsField, + ConfigSettingsRestartBehavior, +} from '../../types/settings'; +import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions'; + +type Leaf = { + path: string; + value: unknown; +}; + +export const LEGACY_HIDDEN_CONFIG_PATHS = [ + 'ankiConnect.deck', + 'ankiConnect.wordField', + 'ankiConnect.audioField', + 'ankiConnect.imageField', + 'ankiConnect.sentenceField', + 'ankiConnect.miscInfoField', + 'ankiConnect.miscInfoPattern', + 'ankiConnect.generateAudio', + 'ankiConnect.generateImage', + 'ankiConnect.imageType', + 'ankiConnect.imageFormat', + 'ankiConnect.imageQuality', + 'ankiConnect.imageMaxWidth', + 'ankiConnect.imageMaxHeight', + 'ankiConnect.animatedFps', + 'ankiConnect.animatedMaxWidth', + 'ankiConnect.animatedMaxHeight', + 'ankiConnect.animatedCrf', + 'ankiConnect.syncAnimatedImageToWordAudio', + 'ankiConnect.audioPadding', + 'ankiConnect.fallbackDuration', + 'ankiConnect.maxMediaDuration', + 'ankiConnect.overwriteAudio', + 'ankiConnect.overwriteImage', + 'ankiConnect.mediaInsertMode', + 'ankiConnect.highlightWord', + 'ankiConnect.notificationType', + 'ankiConnect.autoUpdateNewCards', + 'ankiConnect.nPlusOne.highlightEnabled', + 'ankiConnect.nPlusOne.refreshMinutes', + 'ankiConnect.nPlusOne.matchMode', + 'ankiConnect.nPlusOne.decks', + 'ankiConnect.nPlusOne.knownWord', + 'ankiConnect.behavior.nPlusOneHighlightEnabled', + 'ankiConnect.behavior.nPlusOneRefreshMinutes', + 'ankiConnect.behavior.nPlusOneMatchMode', + 'ankiConnect.isLapis.sentenceCardSentenceField', + 'ankiConnect.isLapis.sentenceCardAudioField', + 'youtubeSubgen.primarySubLanguages', + 'anilist.characterDictionary.refreshTtlHours', + 'anilist.characterDictionary.evictionPolicy', + 'jellyfin.accessToken', + 'jellyfin.userId', + 'controller.buttonIndices', +] as const; + +const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const; + +const JSON_OBJECT_FIELDS = new Set([ + 'keybindings', + 'controller.bindings', + 'controller.profiles', + 'ankiConnect.knownWords.decks', +]); + +const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']); + +const COLOR_SUFFIXES = new Set([ + 'Color', + 'color', + 'backgroundColor', + 'singleColor', + 'knownWordColor', + 'nPlusOne', +]); + +const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry])); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function pathStartsWith(path: string, prefix: string): boolean { + return path === prefix || path.startsWith(`${prefix}.`); +} + +function isLegacyHidden(path: string): boolean { + return ( + LEGACY_HIDDEN_CONFIG_PATHS.some((hiddenPath) => pathStartsWith(path, hiddenPath)) || + EXCLUDED_PREFIXES.some((prefix) => pathStartsWith(path, prefix)) + ); +} + +function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] { + if (JSON_OBJECT_FIELDS.has(prefix)) { + return [{ path: prefix, value }]; + } + + if (Array.isArray(value)) { + return [{ path: prefix, value }]; + } + + if (isRecord(value)) { + const entries = Object.entries(value).filter(([, child]) => child !== undefined); + if (entries.length === 0) { + return [{ path: prefix, value }]; + } + return entries.flatMap(([key, child]) => + flattenConfigLeaves(child, prefix ? `${prefix}.${key}` : key), + ); + } + + return prefix ? [{ path: prefix, value }] : []; +} + +function humanizePath(path: string): string { + const key = path.split('.').at(-1) ?? path; + const spaced = key + .replace(/_/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/\bai\b/i, 'AI') + .replace(/\bmpv\b/i, 'mpv') + .replace(/\byomitan\b/i, 'Yomitan') + .replace(/\bjimaku\b/i, 'Jimaku') + .replace(/\banilist\b/i, 'AniList') + .replace(/\banki\b/i, 'Anki'); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } { + if ( + path === 'subtitleStyle.autoPauseVideoOnHover' || + path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' || + path === 'subtitleSidebar.pauseVideoOnHover' + ) { + return { category: 'viewing', section: 'Playback pause behavior' }; + } + if ( + path.startsWith('ankiConnect.knownWords.') || + path.startsWith('ankiConnect.nPlusOne.') || + path.startsWith('subtitleStyle.frequencyDictionary.') || + path.startsWith('subtitleStyle.jlptColors.') || + path === 'subtitleStyle.enableJlpt' || + path === 'subtitleStyle.nameMatchEnabled' || + path === 'subtitleStyle.nameMatchColor' + ) { + return { category: 'viewing', section: 'Annotation display' }; + } + if (path.startsWith('subtitleStyle.secondary.')) { + return { category: 'viewing', section: 'Secondary subtitle appearance' }; + } + if (path.startsWith('subtitleStyle.')) { + return { category: 'viewing', section: 'Primary subtitle appearance' }; + } + if (path.startsWith('subtitleSidebar.')) { + return { category: 'viewing', section: 'Subtitle sidebar' }; + } + if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) { + return { category: 'viewing', section: 'Subtitle behavior' }; + } + if (path.startsWith('ankiConnect.fields.')) { + return { category: 'mining-anki', section: 'Note fields' }; + } + if (path.startsWith('ankiConnect.media.')) { + return { category: 'mining-anki', section: 'Media capture' }; + } + if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) { + return { category: 'mining-anki', section: 'Kiku and Lapis' }; + } + if (path.startsWith('ankiConnect.ai.')) { + return { category: 'mining-anki', section: 'Anki AI' }; + } + if (path.startsWith('ankiConnect.proxy.')) { + return { category: 'mining-anki', section: 'AnkiConnect proxy' }; + } + if (path.startsWith('ankiConnect.')) { + return { category: 'mining-anki', section: 'AnkiConnect' }; + } + if ( + path.startsWith('mpv.') || + path.startsWith('youtube.') || + path.startsWith('youtubeSubgen.') || + path.startsWith('jimaku.') || + path.startsWith('subsync.') + ) { + return { category: 'playback-sources', section: topSection(path) }; + } + if (path.startsWith('shortcuts.')) { + return { category: 'input', section: 'Overlay shortcuts' }; + } + if (path === 'keybindings') { + return { category: 'input', section: 'MPV keybindings' }; + } + if (path.startsWith('controller.')) { + return { category: 'input', section: 'Controller' }; + } + if ( + path.startsWith('ai.') || + path.startsWith('anilist.') || + path.startsWith('yomitan.') || + path.startsWith('jellyfin.') || + path.startsWith('discordPresence.') || + path.startsWith('websocket.') || + path.startsWith('annotationWebsocket.') || + path.startsWith('texthooker.') + ) { + return { category: 'integrations', section: topSection(path) }; + } + if ( + path.startsWith('immersionTracking.') || + path.startsWith('stats.') || + path.startsWith('updates.') || + path.startsWith('startupWarmups.') || + path.startsWith('logging.') || + path === 'auto_start_overlay' + ) { + return { category: 'tracking-app', section: topSection(path) }; + } + return { category: 'advanced', section: 'Advanced' }; +} + +function topSection(path: string): string { + const top = path.split('.')[0] ?? path; + const labels: Record = { + ai: 'Shared AI provider', + anilist: 'AniList', + annotationWebsocket: 'Annotation WebSocket', + discordPresence: 'Discord Rich Presence', + immersionTracking: 'Immersion tracking', + jimaku: 'Jimaku', + jellyfin: 'Jellyfin', + logging: 'Logging', + mpv: 'mpv launcher', + stats: 'Stats dashboard', + startupWarmups: 'Startup warmups', + subsync: 'Auto subtitle sync', + texthooker: 'Texthooker', + updates: 'Updates', + websocket: 'WebSocket server', + yomitan: 'Yomitan', + youtube: 'YouTube playback', + youtubeSubgen: 'YouTube subtitle generation', + auto_start_overlay: 'Overlay startup', + }; + return labels[top] ?? humanizePath(top); +} + +function controlForPath(path: string, value: unknown): ConfigSettingsControl { + if (SECRET_PATHS.has(path)) return 'secret'; + if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select'; + if (JSON_OBJECT_FIELDS.has(path)) return 'json'; + if (Array.isArray(value)) return 'string-list'; + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'string') { + const leaf = path.split('.').at(-1) ?? path; + if ([...COLOR_SUFFIXES].some((suffix) => leaf.endsWith(suffix))) return 'color'; + if (leaf.toLowerCase().includes('prompt')) return 'textarea'; + return 'text'; + } + return 'json'; +} + +function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior { + if ( + path === 'keybindings' || + pathStartsWith(path, 'shortcuts') || + pathStartsWith(path, 'subtitleStyle') || + pathStartsWith(path, 'subtitleSidebar') || + path === 'secondarySub.defaultMode' || + pathStartsWith(path, 'ankiConnect.ai') + ) { + return 'hot-reload'; + } + return 'restart'; +} + +function fieldForLeaf(leaf: Leaf): ConfigSettingsField { + const option = OPTION_BY_PATH.get(leaf.path); + const { category, section } = categoryAndSection(leaf.path); + return { + id: leaf.path, + label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path), + description: option?.description ?? `${humanizePath(leaf.path)} setting.`, + configPath: leaf.path, + category, + section, + control: controlForPath(leaf.path, leaf.value), + defaultValue: leaf.value, + ...(option?.enumValues ? { enumValues: option.enumValues } : {}), + restartBehavior: restartBehaviorForPath(leaf.path), + advanced: + leaf.path.startsWith('controller.') || + leaf.path.startsWith('immersionTracking.retention.') || + leaf.path.startsWith('youtubeSubgen.'), + secret: SECRET_PATHS.has(leaf.path), + }; +} + +export function buildConfigSettingsRegistry( + defaultConfig: ResolvedConfig = DEFAULT_CONFIG, +): ConfigSettingsField[] { + const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path)); + return leaves.map(fieldForLeaf).sort((a, b) => { + const category = a.category.localeCompare(b.category); + if (category !== 0) return category; + const section = a.section.localeCompare(b.section); + if (section !== 0) return section; + return a.configPath.localeCompare(b.configPath); + }); +} + +export function getConfigSettingsCoverage( + defaultConfig: ResolvedConfig, + fields: ConfigSettingsField[], +): { uncoveredDefaultPaths: string[] } { + const visibleFields = fields.filter((field) => !field.legacyHidden); + const uncoveredDefaultPaths = flattenConfigLeaves(defaultConfig) + .map((leaf) => leaf.path) + .filter((path) => !isLegacyHidden(path)) + .filter( + (path) => + !visibleFields.some( + (field) => + field.configPath === path || + (field.control === 'json' && pathStartsWith(path, field.configPath)), + ), + ) + .sort(); + + return { uncoveredDefaultPaths }; +} + +export function getConfigValueAtPath(root: unknown, path: string): unknown { + let current = root; + for (const segment of path.split('.')) { + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +} diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index eb37add5..b9561a17 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -15,6 +15,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, settings: false, + configSettings: false, setup: false, show: false, hide: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index eef49437..06c943b3 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -16,6 +16,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggle: false, toggleVisibleOverlay: false, settings: false, + configSettings: false, setup: false, show: false, hide: false, @@ -130,6 +131,9 @@ function createDeps(overrides: Partial = {}) { openYomitanSettingsDelayed: (delayMs) => { calls.push(`openYomitanSettingsDelayed:${delayMs}`); }, + openConfigSettingsWindow: () => { + calls.push('openConfigSettingsWindow'); + }, openFirstRunSetup: (force?: boolean) => { calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`); }, @@ -582,6 +586,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () => expected: string; }> = [ { args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' }, + { args: { configSettings: true }, expected: 'openConfigSettingsWindow' }, { args: { showVisibleOverlay: true }, expected: 'setVisibleOverlayVisible:true', diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 635fde11..2a9b5c63 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -43,6 +43,7 @@ export interface CliCommandServiceDeps { togglePrimarySubtitleBar: () => void; openFirstRunSetup: (force?: boolean) => void; openYomitanSettingsDelayed: (delayMs: number) => void; + openConfigSettingsWindow: () => void; setVisibleOverlayVisible: (visible: boolean) => void; copyCurrentSubtitle: () => void; startPendingMultiCopy: (timeoutMs: number) => void; @@ -160,6 +161,7 @@ interface MiningCliRuntime { interface UiCliRuntime { openFirstRunSetup: (force?: boolean) => void; openYomitanSettings: () => void; + openConfigSettingsWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -257,6 +259,7 @@ export function createCliCommandDepsRuntime( options.ui.openYomitanSettings(); }, delayMs); }, + openConfigSettingsWindow: options.ui.openConfigSettingsWindow, setVisibleOverlayVisible: options.overlay.setVisible, copyCurrentSubtitle: options.mining.copyCurrentSubtitle, startPendingMultiCopy: options.mining.startPendingMultiCopy, @@ -385,6 +388,8 @@ export function handleCliCommand( deps.logDebug('Opened first-run setup flow.'); } else if (args.settings) { deps.openYomitanSettingsDelayed(1000); + } else if (args.configSettings) { + deps.openConfigSettingsWindow(); } else if (args.show || args.showVisibleOverlay) { deps.setVisibleOverlayVisible(true); } else if (args.hide || args.hideVisibleOverlay) { diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 1bc82b10..02b1fb67 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -15,6 +15,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, settings: false, + configSettings: false, setup: false, show: false, hide: false, diff --git a/src/core/services/yomitan-extension-copy.ts b/src/core/services/yomitan-extension-copy.ts index d765eed3..259e8089 100644 --- a/src/core/services/yomitan-extension-copy.ts +++ b/src/core/services/yomitan-extension-copy.ts @@ -142,10 +142,7 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string) return sourceHash === null || targetHash === null || sourceHash !== targetHash; } -export function ensureExtensionCopy( - sourceDir: string, - userDataPath: string, -): ExtensionCopyResult { +export function ensureExtensionCopy(sourceDir: string, userDataPath: string): ExtensionCopyResult { if (process.platform === 'win32') { return { targetDir: sourceDir, copied: false }; } diff --git a/src/core/services/yomitan-settings.test.ts b/src/core/services/yomitan-settings.test.ts index f6489395..495615d3 100644 --- a/src/core/services/yomitan-settings.test.ts +++ b/src/core/services/yomitan-settings.test.ts @@ -15,21 +15,24 @@ import { test('yomitan settings window uses a close-only menu without app quit', () => { const calls: string[] = []; - configureYomitanSettingsWindowChrome({ - isDestroyed: () => false, - close: () => calls.push('close'), - setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`), - setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`), - } as never, (template) => { - calls.push(`menu-label:${template[0]?.label ?? ''}`); - const submenu = template[0]?.submenu; - assert.ok(Array.isArray(submenu)); - const closeItem = submenu[0]; - assert.equal(closeItem?.label, 'Close'); - assert.notEqual(closeItem?.role, 'quit'); - closeItem?.click?.({} as never, {} as never, {} as never); - return { id: 'settings-menu' } as never; - }); + configureYomitanSettingsWindowChrome( + { + isDestroyed: () => false, + close: () => calls.push('close'), + setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`), + setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`), + } as never, + (template) => { + calls.push(`menu-label:${template[0]?.label ?? ''}`); + const submenu = template[0]?.submenu; + assert.ok(Array.isArray(submenu)); + const closeItem = submenu[0]; + assert.equal(closeItem?.label, 'Close'); + assert.notEqual(closeItem?.role, 'quit'); + closeItem?.click?.({} as never, {} as never, {} as never); + return { id: 'settings-menu' } as never; + }, + ); assert.deepEqual(calls, ['auto-hide:false', 'menu-label:File', 'close', 'menu:custom']); }); diff --git a/src/main.ts b/src/main.ts index b4402ea0..c2d16bf1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,8 @@ import { BrowserWindow, clipboard, globalShortcut, + ipcMain, + net, shell, protocol, Extension, @@ -75,28 +77,6 @@ function getDefaultPasswordStore(): string { return 'gnome-libsecret'; } -function getStartupModeFlags(initialArgs: CliArgs | null | undefined): { - shouldUseMinimalStartup: boolean; - shouldSkipHeavyStartup: boolean; -} { - return { - shouldUseMinimalStartup: Boolean( - (initialArgs && isStandaloneTexthookerCommand(initialArgs)) || - initialArgs?.update || - (initialArgs?.stats && - (initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)), - ), - shouldSkipHeavyStartup: Boolean( - initialArgs && - (shouldRunSettingsOnlyStartup(initialArgs) || - initialArgs.stats || - initialArgs.dictionary || - initialArgs.update || - initialArgs.setup), - ), - }; -} - protocol.registerSchemesAsPrivileged([ { scheme: 'chrome-extension', @@ -152,15 +132,18 @@ import { commandNeedsOverlayStartupPrereqs, commandNeedsOverlayRuntime, isHeadlessInitialCommand, - isStandaloneTexthookerCommand, parseArgs, - shouldRunSettingsOnlyStartup, shouldStartApp, type CliArgs, type CliCommandSource, } from './cli/args'; import { printHelp } from './cli/help'; import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts'; +import { + getStartupModeFlags, + shouldRefreshAnilistOnConfigReload, + shouldStartAutomaticUpdateChecks, +} from './main/runtime/startup-mode-flags'; import { buildConfigParseErrorDetails, buildConfigWarningDialogDetails, @@ -515,6 +498,8 @@ import { createElectronAppUpdater, isNativeUpdaterSupported, } from './main/runtime/update/app-updater'; +import { createElectronNetFetch } from './main/runtime/update/fetch-adapter'; +import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor'; import { fetchLatestStableRelease, fetchReleaseAssetBuffer, @@ -523,6 +508,7 @@ import { parseSha256Sums, type GitHubRelease, } from './main/runtime/update/release-assets'; +import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy'; import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater'; import { notifyUpdateAvailable } from './main/runtime/update/update-notifications'; import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs'; @@ -541,9 +527,11 @@ import { } from './main/runtime/subtitle-prefetch-runtime'; import { createCreateAnilistSetupWindowHandler, + createCreateConfigSettingsWindowHandler, createCreateFirstRunSetupWindowHandler, createCreateJellyfinSetupWindowHandler, } from './main/runtime/setup-window-factory'; +import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime'; import { isYoutubePlaybackActive } from './main/runtime/youtube-playback'; import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; @@ -577,6 +565,7 @@ import { generateConfigTemplate, } from './config'; import { resolveConfigDir } from './config/path-resolution'; +import { buildConfigSettingsRegistry } from './config/settings/registry'; import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; import { createSubtitlePrefetchService, @@ -835,6 +824,7 @@ const { appState, appLifecycleApp, } = bootServices; +const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG); notifyAnilistTokenStoreWarning = (message: string) => { logger.warn(`[AniList] ${message}`); try { @@ -1777,6 +1767,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp }, }, ); +const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler( + buildConfigHotReloadAppliedMainDepsHandler(), +); const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRuntimeMainDepsHandler( { getCurrentConfig: () => getResolvedConfig(), @@ -1785,9 +1778,7 @@ const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRun setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), clearTimeout: (timeout) => clearTimeout(timeout), debounceMs: 250, - onHotReloadApplied: createConfigHotReloadAppliedHandler( - buildConfigHotReloadAppliedMainDepsHandler(), - ), + onHotReloadApplied: applyConfigHotReloadDiff, onRestartRequired: (fields) => notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)), onInvalidConfig: notifyConfigHotReloadMessage, @@ -1808,6 +1799,32 @@ const configHotReloadRuntime = createConfigHotReloadRuntime( buildConfigHotReloadRuntimeMainDepsHandler(), ); +const configSettingsRuntime = createConfigSettingsRuntime({ + fields: configSettingsFields, + getConfigPath: () => configService.getConfigPath(), + getRawConfig: () => configService.getRawConfig(), + getConfig: () => configService.getConfig(), + getWarnings: () => configService.getWarnings(), + reloadConfigStrict: () => configService.reloadConfigStrict(), + applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config), + getSettingsWindow: () => appState.configSettingsWindow, + setSettingsWindow: (window) => { + appState.configSettingsWindow = window as BrowserWindow | null; + }, + createSettingsWindow: createCreateConfigSettingsWindowHandler({ + createBrowserWindow: (options) => new BrowserWindow(options), + preloadPath: path.join(__dirname, 'preload-settings.js'), + }), + settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'), + openPath: (targetPath) => shell.openPath(targetPath), + ipcMain, + ipcChannels: IPC_CHANNELS.request, + log: (message) => logger.error(message), +}); + +configSettingsRuntime.registerHandlers(); +const openConfigSettingsWindow = () => configSettingsRuntime.openWindow(); + const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({ platform: process.platform, dirname: __dirname, @@ -3759,7 +3776,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ showDesktopNotification: (title, options) => showDesktopNotification(title, options), startConfigHotReload: () => configHotReloadRuntime.start(), shouldRefreshAnilistClientSecretState: () => - !(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), + shouldRefreshAnilistOnConfigReload(appState.initialArgs), refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretStateIfEnabled(options), failHandlers: { logError: (details) => logger.error(details), @@ -4636,9 +4653,12 @@ flushPendingMpvLogWrites = () => { const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json')); let updateService: ReturnType | null = null; +const electronNetFetch = createElectronNetFetch({ + fetch: (url, init) => net.fetch(url, init as RequestInit), +}); function getFetchForUpdater() { - return globalThis.fetch.bind(globalThis); + return electronNetFetch; } async function updateLauncherFromSelectedRelease( @@ -4685,6 +4705,9 @@ function getUpdateService() { isPackaged: app.isPackaged, log: (message) => logger.info(message), getChannel: () => getResolvedConfig().updates.channel, + configureHttpExecutor: + process.platform === 'darwin' ? () => createCurlHttpExecutor() : undefined, + disableDifferentialDownload: process.platform === 'darwin', isNativeUpdaterSupported: () => isNativeUpdaterSupported({ platform: process.platform, @@ -4706,6 +4729,8 @@ function getUpdateService() { readState: () => updateStateStore.readState(), writeState: (state) => updateStateStore.writeState(state), checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel), + shouldFetchReleaseMetadata: ({ appUpdate }) => + shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate), fetchLatestStableRelease: (channel) => fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }), updateLauncher: (launcherPath, channel, release) => @@ -5412,6 +5437,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ }, runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request), openYomitanSettings: () => openYomitanSettings(), + openConfigSettingsWindow: () => openConfigSettingsWindow(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), @@ -5526,7 +5552,7 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers< runAndApplyStartupState(); void app.whenReady().then(() => { - if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { + if (!shouldStartAutomaticUpdateChecks(appState.initialArgs)) { return; } getUpdateService().startAutomaticChecks(); @@ -5621,6 +5647,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = showWindowsMpvLauncherSetup: () => process.platform === 'win32', openYomitanSettings: () => openYomitanSettings(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + openConfigSettingsWindow: () => openConfigSettingsWindow(), openJellyfinSetupWindow: () => openJellyfinSetupWindow(), isJellyfinConfigured: () => isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()), diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 2454d07a..771c2c52 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -46,6 +46,7 @@ export interface CliCommandRuntimeServiceContext { runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand']; runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow']; openYomitanSettings: () => void; + openConfigSettingsWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -127,6 +128,7 @@ function createCliCommandDepsFromContext( ui: { openFirstRunSetup: context.openFirstRunSetup, openYomitanSettings: context.openYomitanSettings, + openConfigSettingsWindow: context.openConfigSettingsWindow, cycleSecondarySubMode: context.cycleSecondarySubMode, openRuntimeOptionsPalette: context.openRuntimeOptionsPalette, printHelp: context.printHelp, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 209e2530..3928deff 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -192,6 +192,7 @@ export interface CliCommandRuntimeServiceDepsParams { ui: { openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; + openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow']; cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode']; openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette']; printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp']; @@ -373,6 +374,7 @@ export function createCliCommandRuntimeServiceDeps( ui: { openFirstRunSetup: params.ui.openFirstRunSetup, openYomitanSettings: params.ui.openYomitanSettings, + openConfigSettingsWindow: params.ui.openConfigSettingsWindow, cycleSecondarySubMode: params.ui.cycleSecondarySubMode, openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette, printHelp: params.ui.printHelp, diff --git a/src/main/runtime/app-runtime-main-deps.ts b/src/main/runtime/app-runtime-main-deps.ts index 4786cffc..dd152aca 100644 --- a/src/main/runtime/app-runtime-main-deps.ts +++ b/src/main/runtime/app-runtime-main-deps.ts @@ -84,8 +84,7 @@ export function createBuildOpenYomitanSettingsMainDepsHandler - deps.getYomitanExtensionLoadInFlight?.() ?? null, + getYomitanExtensionLoadInFlight: () => deps.getYomitanExtensionLoadInFlight?.() ?? null, } : {}), openYomitanSettingsWindow: (params: { diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index d50113f6..e42f6691 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -70,6 +70,7 @@ test('build cli command context deps maps handlers and values', () => { calls.push('run-youtube-playback'); }, openYomitanSettings: () => calls.push('yomitan'), + openConfigSettingsWindow: () => calls.push('config-settings'), cycleSecondarySubMode: () => calls.push('cycle-secondary'), openRuntimeOptionsPalette: () => calls.push('runtime-options'), printHelp: () => calls.push('help'), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index c2ba07f4..4ebd0a38 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -44,6 +44,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand']; runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; openYomitanSettings: () => void; + openConfigSettingsWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -99,6 +100,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { runUpdateCommand: deps.runUpdateCommand, runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, openYomitanSettings: deps.openYomitanSettings, + openConfigSettingsWindow: deps.openConfigSettingsWindow, cycleSecondarySubMode: deps.cycleSecondarySubMode, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, printHelp: deps.printHelp, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index 4fcb925e..3ce28c03 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -74,6 +74,7 @@ test('cli command context factory composes main deps and context handlers', () = runUpdateCommand: async () => {}, runYoutubePlaybackFlow: async () => {}, openYomitanSettings: () => {}, + openConfigSettingsWindow: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, printHelp: () => {}, diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index ff7cbba6..26c3a56e 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -100,6 +100,7 @@ test('cli command context main deps builder maps state and callbacks', async () calls.push('run-youtube-playback'); }, openYomitanSettings: () => calls.push('open-yomitan'), + openConfigSettingsWindow: () => calls.push('open-config-settings'), cycleSecondarySubMode: () => calls.push('cycle-secondary'), openRuntimeOptionsPalette: () => calls.push('open-runtime-options'), printHelp: () => calls.push('help'), @@ -129,6 +130,7 @@ test('cli command context main deps builder maps state and callbacks', async () deps.initializeOverlay(); deps.openFirstRunSetup(true); deps.setVisibleOverlay(true); + deps.openConfigSettingsWindow(); deps.printHelp(); await deps.runUpdateCommand({ update: true } as never, 'initial'); @@ -137,6 +139,7 @@ test('cli command context main deps builder maps state and callbacks', async () 'init-overlay', 'open-setup:force', 'set-visible:true', + 'open-config-settings', 'help', 'run-update', ]); diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index c3b2e65c..9835ea4c 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -57,6 +57,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; openYomitanSettings: () => void; + openConfigSettingsWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -127,6 +128,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { deps.runUpdateCommand(args, source), runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request), openYomitanSettings: () => deps.openYomitanSettings(), + openConfigSettingsWindow: () => deps.openConfigSettingsWindow(), cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), printHelp: () => deps.printHelp(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index 110018d5..0e20677b 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -56,6 +56,7 @@ function createDeps() { runUpdateCommand: async () => {}, runYoutubePlaybackFlow: async () => {}, openYomitanSettings: () => {}, + openConfigSettingsWindow: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, printHelp: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index 93cad14b..e622cf25 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -49,6 +49,7 @@ export type CliCommandContextFactoryDeps = { runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand']; runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow']; openYomitanSettings: () => void; + openConfigSettingsWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -126,6 +127,7 @@ export function createCliCommandContext( runUpdateCommand: deps.runUpdateCommand, runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, openYomitanSettings: deps.openYomitanSettings, + openConfigSettingsWindow: deps.openConfigSettingsWindow, cycleSecondarySubMode: deps.cycleSecondarySubMode, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, printHelp: deps.printHelp, diff --git a/src/main/runtime/command-line-launcher-windows.ts b/src/main/runtime/command-line-launcher-windows.ts index 87bbb845..18a0fd34 100644 --- a/src/main/runtime/command-line-launcher-windows.ts +++ b/src/main/runtime/command-line-launcher-windows.ts @@ -47,10 +47,7 @@ export function getUserPath(options: CommonOptions & WindowsPathOptions): string return options.getUserPath?.() ?? envOf(options).Path ?? envOf(options).PATH ?? ''; } -async function setWindowsUserPath( - options: CommonOptions & WindowsPathOptions, - nextPath: string, -) { +async function setWindowsUserPath(options: CommonOptions & WindowsPathOptions, nextPath: string) { if (options.setUserPath) { await options.setUserPath(nextPath); return; @@ -96,6 +93,7 @@ export async function appendWindowsUserPathDir( } export function defaultBunRepairPath(options: CommonOptions & WindowsPathOptions): string { - const userProfile = options.userProfile ?? envOf(options).USERPROFILE ?? options.homeDir ?? os.homedir(); + const userProfile = + options.userProfile ?? envOf(options).USERPROFILE ?? options.homeDir ?? os.homedir(); return path.win32.join(userProfile, '.bun', 'bin'); } diff --git a/src/main/runtime/composers/cli-startup-composer.test.ts b/src/main/runtime/composers/cli-startup-composer.test.ts index 4853222b..f69d1d38 100644 --- a/src/main/runtime/composers/cli-startup-composer.test.ts +++ b/src/main/runtime/composers/cli-startup-composer.test.ts @@ -50,6 +50,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => { runUpdateCommand: async () => {}, runYoutubePlaybackFlow: async () => {}, openYomitanSettings: () => {}, + openConfigSettingsWindow: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, printHelp: () => {}, diff --git a/src/main/runtime/config-settings-ipc.test.ts b/src/main/runtime/config-settings-ipc.test.ts new file mode 100644 index 00000000..891383f9 --- /dev/null +++ b/src/main/runtime/config-settings-ipc.test.ts @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isConfigSettingsPatch } from './config-settings-ipc'; +import type { ConfigSettingsField } from '../../types/settings'; + +const fields: ConfigSettingsField[] = [ + { + id: 'mpv.launchMode', + label: 'Launch mode', + description: 'Launch mode setting.', + configPath: 'mpv.launchMode', + category: 'playback-sources', + section: 'mpv launcher', + control: 'select', + defaultValue: 'windowed', + restartBehavior: 'restart', + }, +]; + +test('isConfigSettingsPatch rejects set operations without a value property', () => { + assert.equal( + isConfigSettingsPatch( + { + operations: [{ op: 'set', path: 'mpv.launchMode' }], + }, + fields, + ), + false, + ); +}); + +test('isConfigSettingsPatch accepts set operations with an explicit value', () => { + assert.equal( + isConfigSettingsPatch( + { + operations: [{ op: 'set', path: 'mpv.launchMode', value: 'fullscreen' }], + }, + fields, + ), + true, + ); +}); + +test('isConfigSettingsPatch accepts reset operations without a value', () => { + assert.equal( + isConfigSettingsPatch( + { + operations: [{ op: 'reset', path: 'mpv.launchMode' }], + }, + fields, + ), + true, + ); +}); + +test('isConfigSettingsPatch rejects unknown config paths', () => { + assert.equal( + isConfigSettingsPatch( + { + operations: [{ op: 'reset', path: 'unknown.path' }], + }, + fields, + ), + false, + ); +}); diff --git a/src/main/runtime/config-settings-ipc.ts b/src/main/runtime/config-settings-ipc.ts new file mode 100644 index 00000000..3fa5dc82 --- /dev/null +++ b/src/main/runtime/config-settings-ipc.ts @@ -0,0 +1,30 @@ +import type { ConfigSettingsField, ConfigSettingsPatch } from '../../types/settings'; + +export function isConfigSettingsPatch( + value: unknown, + fields: readonly ConfigSettingsField[], +): value is ConfigSettingsPatch { + if (!value || typeof value !== 'object') { + return false; + } + const operations = (value as { operations?: unknown }).operations; + return ( + Array.isArray(operations) && + operations.every((operation) => { + if (!operation || typeof operation !== 'object') { + return false; + } + const candidate = operation as { op?: unknown; path?: unknown; value?: unknown }; + const knownPath = + typeof candidate.path === 'string' && + fields.some((field) => field.configPath === candidate.path); + if (!knownPath) { + return false; + } + if (candidate.op === 'set') { + return 'value' in candidate; + } + return candidate.op === 'reset'; + }) + ); +} diff --git a/src/main/runtime/config-settings-runtime.ts b/src/main/runtime/config-settings-runtime.ts new file mode 100644 index 00000000..8a38d6de --- /dev/null +++ b/src/main/runtime/config-settings-runtime.ts @@ -0,0 +1,166 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit'; +import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config'; +import type { + ConfigSettingsField, + ConfigSettingsSaveResult, + ConfigSettingsSnapshot, +} from '../../types/settings'; +import type { ReloadConfigStrictResult } from '../../config'; +import { + classifyConfigHotReloadDiff, + type ConfigHotReloadDiff, +} from '../../core/services/config-hot-reload'; +import { createSaveConfigSettingsPatchHandler } from './config-settings-save'; +import { + createOpenConfigSettingsWindowHandler, + type ConfigSettingsWindowLike, +} from './config-settings-window'; +import { isConfigSettingsPatch } from './config-settings-ipc'; + +export interface ConfigSettingsIpcMainLike { + handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown; +} + +export interface ConfigSettingsIpcChannels { + getConfigSettingsSnapshot: string; + saveConfigSettingsPatch: string; + openConfigSettingsFile: string; + openConfigSettingsWindow: string; +} + +export interface ConfigSettingsRuntimeDeps { + fields: ConfigSettingsField[]; + getConfigPath(): string; + getRawConfig(): RawConfig; + getConfig(): ResolvedConfig; + getWarnings(): ConfigValidationWarning[]; + reloadConfigStrict(): ReloadConfigStrictResult; + applyHotReload(diff: ConfigHotReloadDiff, config: ResolvedConfig): void; + getSettingsWindow(): TWindow | null; + setSettingsWindow(window: TWindow | null): void; + createSettingsWindow(): TWindow; + settingsHtmlPath: string; + openPath(path: string): Promise; + ipcMain: ConfigSettingsIpcMainLike; + ipcChannels: ConfigSettingsIpcChannels; + log?: (message: string) => void; +} + +export function writeTextFileAtomically(targetPath: string, content: string): void { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const tempPath = path.join( + path.dirname(targetPath), + `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`, + ); + try { + fs.writeFileSync(tempPath, content, 'utf-8'); + fs.renameSync(tempPath, targetPath); + } catch (error) { + try { + fs.rmSync(tempPath, { force: true }); + } catch { + // Best effort cleanup after a failed atomic write. + } + throw error; + } +} + +function getRestartRequiredSettingsSections( + fields: readonly ConfigSettingsField[], + restartRequiredFields: string[], +): string[] { + const sections = new Set(); + for (const field of fields) { + if ( + restartRequiredFields.some( + (restartField) => + field.configPath === restartField || + field.configPath.startsWith(`${restartField}.`) || + restartField.startsWith(`${field.configPath}.`), + ) + ) { + sections.add(field.section); + } + } + return [...sections].sort(); +} + +export function createConfigSettingsRuntime( + deps: ConfigSettingsRuntimeDeps, +) { + function getSnapshot(): ConfigSettingsSnapshot { + return buildConfigSettingsSnapshot({ + configPath: deps.getConfigPath(), + rawConfig: deps.getRawConfig(), + resolvedConfig: deps.getConfig(), + warnings: deps.getWarnings(), + fields: deps.fields, + }); + } + + const savePatch = createSaveConfigSettingsPatchHandler({ + getConfigPath: () => deps.getConfigPath(), + getCurrentConfig: () => deps.getConfig(), + getWarnings: () => deps.getWarnings(), + getSnapshot, + fileExists: (targetPath) => fs.existsSync(targetPath), + readText: (targetPath) => fs.readFileSync(targetPath, 'utf-8'), + writeTextAtomically: (targetPath, content) => writeTextFileAtomically(targetPath, content), + deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }), + reloadConfigStrict: () => deps.reloadConfigStrict(), + classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next), + applyHotReload: (diff, config) => deps.applyHotReload(diff, config), + getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields), + }); + + function ensureConfigFileExists(): string { + const configPath = deps.getConfigPath(); + if (!fs.existsSync(configPath)) { + writeTextFileAtomically(configPath, '{}\n'); + } + return configPath; + } + + const openWindow = createOpenConfigSettingsWindowHandler({ + getSettingsWindow: deps.getSettingsWindow, + setSettingsWindow: deps.setSettingsWindow, + createSettingsWindow: deps.createSettingsWindow, + settingsHtmlPath: deps.settingsHtmlPath, + log: deps.log, + }); + + function invalidPatchResult(): ConfigSettingsSaveResult { + return { + ok: false, + warnings: [], + error: 'Invalid config settings patch.', + hotReloadFields: [], + restartRequiredFields: [], + restartRequiredSections: [], + }; + } + + function registerHandlers(): void { + deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot()); + deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => { + if (!isConfigSettingsPatch(patch, deps.fields)) { + return invalidPatchResult(); + } + return savePatch(patch); + }); + deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsFile, async () => { + const openError = await deps.openPath(ensureConfigFileExists()); + return openError.length === 0; + }); + deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow()); + } + + return { + getSnapshot, + savePatch, + openWindow, + registerHandlers, + }; +} diff --git a/src/main/runtime/config-settings-save.test.ts b/src/main/runtime/config-settings-save.test.ts new file mode 100644 index 00000000..626be3db --- /dev/null +++ b/src/main/runtime/config-settings-save.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DEFAULT_CONFIG, type ReloadConfigStrictResult } from '../../config'; +import type { ResolvedConfig } from '../../types/config'; +import type { ConfigSettingsSnapshot } from '../../types/settings'; +import { createSaveConfigSettingsPatchHandler } from './config-settings-save'; + +function snapshot(): ConfigSettingsSnapshot { + return { + configPath: '/tmp/config.jsonc', + fields: [], + values: {}, + warnings: [], + }; +} + +test('config settings save applies hot-reloadable diff live', () => { + const calls: string[] = []; + const previous = DEFAULT_CONFIG; + const next: ResolvedConfig = { + ...DEFAULT_CONFIG, + subtitleStyle: { + ...DEFAULT_CONFIG.subtitleStyle, + autoPauseVideoOnHover: false, + }, + }; + let written = ''; + const save = createSaveConfigSettingsPatchHandler({ + getConfigPath: () => '/tmp/config.jsonc', + getCurrentConfig: () => previous, + getWarnings: () => [], + getSnapshot: () => snapshot(), + fileExists: () => true, + readText: () => '{}', + writeTextAtomically: (_path, content) => { + written = content; + calls.push('write'); + }, + reloadConfigStrict: (): ReloadConfigStrictResult => ({ + ok: true, + config: next, + warnings: [], + path: '/tmp/config.jsonc', + }), + classifyDiff: () => ({ + hotReloadFields: ['subtitleStyle'], + restartRequiredFields: [], + }), + applyHotReload: (diff) => calls.push(`hot:${diff.hotReloadFields.join(',')}`), + getRestartRequiredSections: () => [], + }); + + const result = save({ + operations: [ + { + op: 'set', + path: 'subtitleStyle.autoPauseVideoOnHover', + value: false, + }, + ], + }); + + assert.equal(result.ok, true); + assert.match(written, /autoPauseVideoOnHover/); + assert.deepEqual(calls, ['write', 'hot:subtitleStyle']); + assert.deepEqual(result.hotReloadFields, ['subtitleStyle']); + assert.deepEqual(result.restartRequiredFields, []); +}); + +test('config settings save returns restart-required sections without applying hot reload', () => { + const calls: string[] = []; + const previous = DEFAULT_CONFIG; + const next: ResolvedConfig = { + ...DEFAULT_CONFIG, + mpv: { + ...DEFAULT_CONFIG.mpv, + launchMode: 'fullscreen', + }, + }; + const save = createSaveConfigSettingsPatchHandler({ + getConfigPath: () => '/tmp/config.jsonc', + getCurrentConfig: () => previous, + getWarnings: () => [], + getSnapshot: () => snapshot(), + fileExists: () => true, + readText: () => '{}', + writeTextAtomically: () => calls.push('write'), + reloadConfigStrict: (): ReloadConfigStrictResult => ({ + ok: true, + config: next, + warnings: [], + path: '/tmp/config.jsonc', + }), + classifyDiff: () => ({ + hotReloadFields: [], + restartRequiredFields: ['mpv'], + }), + applyHotReload: () => calls.push('hot'), + getRestartRequiredSections: () => ['mpv launcher'], + }); + + const result = save({ + operations: [{ op: 'set', path: 'mpv.launchMode', value: 'fullscreen' }], + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ['write']); + assert.deepEqual(result.hotReloadFields, []); + assert.deepEqual(result.restartRequiredFields, ['mpv']); + assert.deepEqual(result.restartRequiredSections, ['mpv launcher']); +}); + +test('config settings save restores previous file content when strict reload fails', () => { + const writes: string[] = []; + const save = createSaveConfigSettingsPatchHandler({ + getConfigPath: () => '/tmp/config.jsonc', + getCurrentConfig: () => DEFAULT_CONFIG, + getWarnings: () => [], + getSnapshot: () => snapshot(), + fileExists: () => true, + readText: () => '{"mpv":{"launchMode":"normal"}}\n', + writeTextAtomically: (_path, content) => { + writes.push(content); + }, + reloadConfigStrict: (): ReloadConfigStrictResult => ({ + ok: false, + error: 'invalid config', + path: '/tmp/config.jsonc', + }), + classifyDiff: () => { + throw new Error('Should not classify invalid config.'); + }, + applyHotReload: () => { + throw new Error('Should not hot reload invalid config.'); + }, + getRestartRequiredSections: () => [], + }); + + const result = save({ + operations: [{ op: 'set', path: 'mpv.launchMode', value: 'fullscreen' }], + }); + + assert.equal(result.ok, false); + assert.equal(result.error, 'invalid config'); + assert.equal(writes.length, 2); + assert.match(writes[0] ?? '', /fullscreen/); + assert.equal(writes[1], '{"mpv":{"launchMode":"normal"}}\n'); +}); diff --git a/src/main/runtime/config-settings-save.ts b/src/main/runtime/config-settings-save.ts new file mode 100644 index 00000000..ed257e03 --- /dev/null +++ b/src/main/runtime/config-settings-save.ts @@ -0,0 +1,98 @@ +import type { ReloadConfigStrictResult } from '../../config'; +import { applyConfigSettingsPatchToContent } from '../../config/settings/jsonc-edit'; +import type { ConfigValidationWarning, ResolvedConfig } from '../../types/config'; +import type { + ConfigSettingsPatch, + ConfigSettingsSaveResult, + ConfigSettingsSnapshot, +} from '../../types/settings'; + +export interface ConfigSettingsHotReloadDiff { + hotReloadFields: string[]; + restartRequiredFields: string[]; +} + +export interface ConfigSettingsSaveDeps { + getConfigPath(): string; + getCurrentConfig(): ResolvedConfig; + getWarnings(): ConfigValidationWarning[]; + getSnapshot(): ConfigSettingsSnapshot; + fileExists(path: string): boolean; + readText(path: string): string; + writeTextAtomically(path: string, content: string): void; + deleteFile?(path: string): void; + reloadConfigStrict(): ReloadConfigStrictResult; + classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff; + applyHotReload(diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig): void; + getRestartRequiredSections(restartRequiredFields: string[]): string[]; +} + +export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) { + return (patch: ConfigSettingsPatch): ConfigSettingsSaveResult => { + if (patch.operations.length === 0) { + return { + ok: true, + snapshot: deps.getSnapshot(), + hotReloadFields: [], + restartRequiredFields: [], + restartRequiredSections: [], + }; + } + + const configPath = deps.getConfigPath(); + const previousConfig = deps.getCurrentConfig(); + const previousWarnings = deps.getWarnings(); + const hadExistingConfig = deps.fileExists(configPath); + const content = hadExistingConfig ? deps.readText(configPath) : '{}\n'; + const candidate = applyConfigSettingsPatchToContent({ + content, + operations: patch.operations, + previousWarnings, + }); + + if (!candidate.ok) { + return { + ok: false, + warnings: candidate.warnings, + error: candidate.error, + hotReloadFields: [], + restartRequiredFields: [], + restartRequiredSections: [], + }; + } + + deps.writeTextAtomically(configPath, candidate.content); + const reloadResult = deps.reloadConfigStrict(); + if (!reloadResult.ok) { + if (hadExistingConfig) { + deps.writeTextAtomically(configPath, content); + } else if (deps.deleteFile) { + deps.deleteFile(configPath); + } else { + deps.writeTextAtomically(configPath, content); + } + return { + ok: false, + warnings: [], + error: reloadResult.error, + hotReloadFields: [], + restartRequiredFields: [], + restartRequiredSections: [], + }; + } + + const diff = deps.classifyDiff(previousConfig, reloadResult.config); + if (diff.hotReloadFields.length > 0) { + deps.applyHotReload(diff, reloadResult.config); + } + + return { + ok: true, + snapshot: deps.getSnapshot(), + warnings: reloadResult.warnings, + hotReloadFields: diff.hotReloadFields, + restartRequiredFields: diff.restartRequiredFields, + restartRequiredSections: deps.getRestartRequiredSections(diff.restartRequiredFields), + }; + }; +} diff --git a/src/main/runtime/config-settings-window.test.ts b/src/main/runtime/config-settings-window.test.ts new file mode 100644 index 00000000..519a0157 --- /dev/null +++ b/src/main/runtime/config-settings-window.test.ts @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createOpenConfigSettingsWindowHandler } from './config-settings-window'; + +test('createOpenConfigSettingsWindowHandler focuses existing settings window', () => { + const calls: string[] = []; + const existing = { + isDestroyed: () => false, + focus: () => calls.push('focus'), + loadFile: () => calls.push('load'), + on: () => {}, + }; + + const open = createOpenConfigSettingsWindowHandler({ + getSettingsWindow: () => existing, + setSettingsWindow: () => calls.push('set'), + createSettingsWindow: () => { + throw new Error('Should not create a second window.'); + }, + settingsHtmlPath: '/tmp/settings.html', + }); + + assert.equal(open(), true); + assert.deepEqual(calls, ['focus']); +}); + +test('createOpenConfigSettingsWindowHandler creates window and clears closed state', () => { + const calls: string[] = []; + const handlers: { closed?: () => void } = {}; + const created = { + isDestroyed: () => false, + focus: () => calls.push('focus'), + loadFile: (path: string) => calls.push(`load:${path}`), + on: (event: string, handler: () => void) => { + if (event === 'closed') handlers.closed = handler; + }, + }; + + const open = createOpenConfigSettingsWindowHandler({ + getSettingsWindow: () => null, + setSettingsWindow: (window) => calls.push(window ? 'set:window' : 'set:null'), + createSettingsWindow: () => created, + settingsHtmlPath: '/tmp/settings.html', + }); + + assert.equal(open(), true); + assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'focus']); + assert.ok(handlers.closed); + handlers.closed(); + assert.equal(calls.at(-1), 'set:null'); +}); + +test('createOpenConfigSettingsWindowHandler clears failed load window state', async () => { + const calls: string[] = []; + const created = { + isDestroyed: () => false, + focus: () => calls.push('focus'), + loadFile: (path: string) => { + calls.push(`load:${path}`); + return Promise.reject(new Error('missing settings html')); + }, + on: () => {}, + destroy: () => calls.push('destroy'), + }; + + const open = createOpenConfigSettingsWindowHandler({ + getSettingsWindow: () => null, + setSettingsWindow: (window) => calls.push(window ? 'set:window' : 'set:null'), + createSettingsWindow: () => created, + settingsHtmlPath: '/tmp/missing-settings.html', + }); + + assert.equal(open(), true); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(calls, [ + 'load:/tmp/missing-settings.html', + 'set:window', + 'focus', + 'set:null', + 'destroy', + ]); +}); diff --git a/src/main/runtime/config-settings-window.ts b/src/main/runtime/config-settings-window.ts new file mode 100644 index 00000000..f5804b81 --- /dev/null +++ b/src/main/runtime/config-settings-window.ts @@ -0,0 +1,41 @@ +export interface ConfigSettingsWindowLike { + isDestroyed(): boolean; + focus(): void; + loadFile(path: string): unknown; + on(event: 'closed', handler: () => void): unknown; + destroy?(): unknown; +} + +export interface OpenConfigSettingsWindowDeps { + getSettingsWindow(): TWindow | null; + setSettingsWindow(window: TWindow | null): void; + createSettingsWindow(): TWindow; + settingsHtmlPath: string; + log?: (message: string) => void; +} + +export function createOpenConfigSettingsWindowHandler( + deps: OpenConfigSettingsWindowDeps, +): () => boolean { + return () => { + const existing = deps.getSettingsWindow(); + if (existing && !existing.isDestroyed()) { + existing.focus(); + return true; + } + + const window = deps.createSettingsWindow(); + void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + deps.log?.(`Failed to load configuration settings window: ${message}`); + deps.setSettingsWindow(null); + window.destroy?.(); + }); + deps.setSettingsWindow(window); + window.on('closed', () => { + deps.setSettingsWindow(null); + }); + window.focus(); + return true; + }; +} diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 22f62ee3..cbf0b3dd 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -30,6 +30,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, settings: false, + configSettings: false, setup: false, show: false, hide: false, @@ -120,6 +121,7 @@ function createCommandLineLauncherSnapshot( test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => { assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true); assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true); + assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, configSettings: true })), false); assert.equal( shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })), false, diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index a2f1b9f8..6ea578ce 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -72,6 +72,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { args.togglePrimarySubtitleBar || args.launchMpv || args.settings || + args.configSettings || args.show || args.hide || args.showVisibleOverlay || diff --git a/src/main/runtime/setup-window-factory.test.ts b/src/main/runtime/setup-window-factory.test.ts index 1c2504f8..8f6f02af 100644 --- a/src/main/runtime/setup-window-factory.test.ts +++ b/src/main/runtime/setup-window-factory.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createCreateAnilistSetupWindowHandler, + createCreateConfigSettingsWindowHandler, createCreateFirstRunSetupWindowHandler, createCreateJellyfinSetupWindowHandler, } from './setup-window-factory'; @@ -77,3 +78,31 @@ test('createCreateAnilistSetupWindowHandler builds anilist setup window', () => }, }); }); + +test('createCreateConfigSettingsWindowHandler builds configuration settings window', () => { + let options: Electron.BrowserWindowConstructorOptions | null = null; + const createSettingsWindow = createCreateConfigSettingsWindowHandler({ + preloadPath: '/tmp/preload-settings.js', + createBrowserWindow: (nextOptions) => { + options = nextOptions; + return { id: 'config-settings' } as never; + }, + }); + + assert.deepEqual(createSettingsWindow(), { id: 'config-settings' }); + assert.deepEqual(options, { + width: 1040, + height: 760, + title: 'SubMiner Configuration', + show: true, + autoHideMenuBar: true, + resizable: true, + backgroundColor: '#24273a', + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: false, + preload: '/tmp/preload-settings.js', + }, + }); +}); diff --git a/src/main/runtime/setup-window-factory.ts b/src/main/runtime/setup-window-factory.ts index 730b9789..22a45986 100644 --- a/src/main/runtime/setup-window-factory.ts +++ b/src/main/runtime/setup-window-factory.ts @@ -5,6 +5,9 @@ interface SetupWindowConfig { resizable?: boolean; minimizable?: boolean; maximizable?: boolean; + preloadPath?: string; + sandbox?: boolean; + backgroundColor?: string; } function createSetupWindowHandler( @@ -21,9 +24,12 @@ function createSetupWindowHandler( ...(config.resizable === undefined ? {} : { resizable: config.resizable }), ...(config.minimizable === undefined ? {} : { minimizable: config.minimizable }), ...(config.maximizable === undefined ? {} : { maximizable: config.maximizable }), + ...(config.backgroundColor === undefined ? {} : { backgroundColor: config.backgroundColor }), webPreferences: { nodeIntegration: false, contextIsolation: true, + ...(config.sandbox === undefined ? {} : { sandbox: config.sandbox }), + ...(config.preloadPath ? { preload: config.preloadPath } : {}), }, }); } @@ -60,3 +66,18 @@ export function createCreateAnilistSetupWindowHandler(deps: { title: 'Anilist Setup', }); } + +export function createCreateConfigSettingsWindowHandler(deps: { + createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; + preloadPath: string; +}) { + return createSetupWindowHandler(deps, { + width: 1040, + height: 760, + title: 'SubMiner Configuration', + resizable: true, + preloadPath: deps.preloadPath, + sandbox: false, + backgroundColor: '#24273a', + }); +} diff --git a/src/main/runtime/startup-mode-flags.test.ts b/src/main/runtime/startup-mode-flags.test.ts new file mode 100644 index 00000000..6bb8dd91 --- /dev/null +++ b/src/main/runtime/startup-mode-flags.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseArgs } from '../../cli/args'; +import { + getStartupModeFlags, + shouldRefreshAnilistOnConfigReload, + shouldStartAutomaticUpdateChecks, +} from './startup-mode-flags'; + +test('config settings startup uses minimal startup and skips background integrations', () => { + const args = parseArgs(['--config']); + const flags = getStartupModeFlags(args); + + assert.equal(flags.shouldUseMinimalStartup, true); + assert.equal(flags.shouldSkipHeavyStartup, true); + assert.equal(shouldRefreshAnilistOnConfigReload(args), false); + assert.equal(shouldStartAutomaticUpdateChecks(args), false); +}); + +test('normal startup still allows background integrations', () => { + const flags = getStartupModeFlags(null); + + assert.equal(flags.shouldUseMinimalStartup, false); + assert.equal(flags.shouldSkipHeavyStartup, false); + assert.equal(shouldRefreshAnilistOnConfigReload(null), true); + assert.equal(shouldStartAutomaticUpdateChecks(null), true); +}); diff --git a/src/main/runtime/startup-mode-flags.ts b/src/main/runtime/startup-mode-flags.ts new file mode 100644 index 00000000..2d9a015f --- /dev/null +++ b/src/main/runtime/startup-mode-flags.ts @@ -0,0 +1,40 @@ +import type { CliArgs } from '../../cli/args'; +import { + isHeadlessInitialCommand, + isStandaloneTexthookerCommand, + shouldRunSettingsOnlyStartup, +} from '../../cli/args'; + +export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): { + shouldUseMinimalStartup: boolean; + shouldSkipHeavyStartup: boolean; +} { + return { + shouldUseMinimalStartup: Boolean( + (initialArgs && isStandaloneTexthookerCommand(initialArgs)) || + initialArgs?.configSettings || + initialArgs?.update || + (initialArgs?.stats && + (initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)), + ), + shouldSkipHeavyStartup: Boolean( + initialArgs && + (shouldRunSettingsOnlyStartup(initialArgs) || + initialArgs.configSettings || + initialArgs.stats || + initialArgs.dictionary || + initialArgs.update || + initialArgs.setup), + ), + }; +} + +export function shouldRefreshAnilistOnConfigReload( + initialArgs: CliArgs | null | undefined, +): boolean { + return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings)); +} + +export function shouldStartAutomaticUpdateChecks(initialArgs: CliArgs | null | undefined): boolean { + return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings)); +} diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 2d9b2785..f330b5b0 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -48,6 +48,7 @@ test('build tray template handler wires actions and init guards', () => { handlers.openWindowsMpvLauncherSetup(); handlers.openYomitanSettings(); handlers.openRuntimeOptions(); + handlers.openConfigSettings(); handlers.openJellyfinSetup(); handlers.toggleJellyfinDiscovery(); handlers.openAnilistSetup(); @@ -68,6 +69,7 @@ test('build tray template handler wires actions and init guards', () => { showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openRuntimeOptionsPalette: () => calls.push('runtime-options'), + openConfigSettingsWindow: () => calls.push('configuration'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, isJellyfinDiscoveryActive: () => false, @@ -90,6 +92,7 @@ test('build tray template handler wires actions and init guards', () => { 'setup', 'yomitan', 'runtime-options', + 'configuration', 'jellyfin', 'jellyfin-discovery', 'anilist', diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index 5f0b9caa..548cd88d 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openRuntimeOptions: () => void; + openConfigSettings: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; @@ -55,6 +56,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; openRuntimeOptionsPalette: () => void; + openConfigSettingsWindow: () => void; openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; isJellyfinDiscoveryActive: () => boolean; @@ -92,6 +94,9 @@ export function createBuildTrayMenuTemplateHandler(deps: { } deps.openRuntimeOptionsPalette(); }, + openConfigSettings: () => { + deps.openConfigSettingsWindow(); + }, openJellyfinSetup: () => { deps.openJellyfinSetupWindow(); }, diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index 1a6f0f81..6eb92daa 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -32,6 +32,7 @@ test('tray main deps builders return mapped handlers', () => { showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openRuntimeOptionsPalette: () => calls.push('runtime-options'), + openConfigSettingsWindow: () => calls.push('configuration'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, isJellyfinDiscoveryActive: () => false, @@ -53,6 +54,7 @@ test('tray main deps builders return mapped handlers', () => { showWindowsMpvLauncherSetup: true, openYomitanSettings: () => calls.push('open-yomitan'), openRuntimeOptions: () => calls.push('open-runtime-options'), + openConfigSettings: () => calls.push('open-configuration'), openJellyfinSetup: () => calls.push('open-jellyfin'), showJellyfinDiscovery: true, jellyfinDiscoveryActive: false, diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index 955b0b52..0279c5b6 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -36,6 +36,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openRuntimeOptions: () => void; + openConfigSettings: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; @@ -54,6 +55,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; openRuntimeOptionsPalette: () => void; + openConfigSettingsWindow: () => void; openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; isJellyfinDiscoveryActive: () => boolean; @@ -74,6 +76,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup, openYomitanSettings: deps.openYomitanSettings, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, + openConfigSettingsWindow: deps.openConfigSettingsWindow, openJellyfinSetupWindow: deps.openJellyfinSetupWindow, isJellyfinConfigured: deps.isJellyfinConfigured, isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index 41eb13cd..f49bac67 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => {}, openRuntimeOptionsPalette: () => {}, + openConfigSettingsWindow: () => {}, openJellyfinSetupWindow: () => {}, isJellyfinConfigured: () => false, isJellyfinDiscoveryActive: () => false, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 541c1430..28f089ab 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -38,6 +38,7 @@ test('tray menu template contains expected entries and handlers', () => { showWindowsMpvLauncherSetup: true, openYomitanSettings: () => calls.push('yomitan'), openRuntimeOptions: () => calls.push('runtime'), + openConfigSettings: () => calls.push('configuration'), openJellyfinSetup: () => calls.push('jellyfin'), showJellyfinDiscovery: true, jellyfinDiscoveryActive: false, @@ -47,7 +48,7 @@ test('tray menu template contains expected entries and handlers', () => { quitApp: () => calls.push('quit'), }); - assert.equal(template.length, 12); + assert.equal(template.length, 13); assert.equal( template.some((entry) => entry.label === 'Open Overlay'), false, @@ -60,10 +61,10 @@ test('tray menu template contains expected entries and handlers', () => { template[0]!.click?.(); assert.equal(template[1]!.label, 'Open Texthooker'); template[1]!.click?.(); - assert.equal(template[9]!.label, 'Check for Updates'); - template[9]!.click?.(); - template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); - template[11]!.click?.(); + assert.equal(template[10]!.label, 'Check for Updates'); + template[10]!.click?.(); + template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); + template[12]!.click?.(); assert.deepEqual(calls, [ 'jellyfin-discovery', 'help', @@ -85,6 +86,7 @@ test('tray menu template omits first-run setup entry when setup is complete', () showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openRuntimeOptions: () => undefined, + openConfigSettings: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: false, jellyfinDiscoveryActive: false, @@ -112,6 +114,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openRuntimeOptions: () => undefined, + openConfigSettings: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: false, jellyfinDiscoveryActive: false, @@ -137,6 +140,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => { showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openRuntimeOptions: () => undefined, + openConfigSettings: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: true, jellyfinDiscoveryActive: true, diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 55df81b6..12afa864 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -39,6 +39,7 @@ export type TrayMenuActionHandlers = { showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openRuntimeOptions: () => void; + openConfigSettings: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; @@ -92,6 +93,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): label: 'Open Runtime Options', click: handlers.openRuntimeOptions, }, + { + label: 'Open Configuration', + click: handlers.openConfigSettings, + }, { label: 'Configure Jellyfin', click: handlers.openJellyfinSetup, diff --git a/src/main/runtime/update/app-updater.test.ts b/src/main/runtime/update/app-updater.test.ts index 3cf4b647..400e9090 100644 --- a/src/main/runtime/update/app-updater.test.ts +++ b/src/main/runtime/update/app-updater.test.ts @@ -162,6 +162,46 @@ test('app updater skips native downloads when native updater is unsupported', as assert.deepEqual(logged, ['Skipping app update download because native updater is unsupported.']); }); +test('app updater installs a custom HTTP executor before native checks', async () => { + const httpExecutor = { request: async () => null }; + let executorDuringCheck: unknown; + let differentialDownloadDuringCheck: unknown; + const updater: ElectronAutoUpdaterLike & { + httpExecutor?: unknown; + disableDifferentialDownload?: boolean; + } = { + autoDownload: true, + allowPrerelease: false, + allowDowngrade: true, + logger: null, + checkForUpdates: async () => { + executorDuringCheck = updater.httpExecutor; + differentialDownloadDuringCheck = updater.disableDifferentialDownload; + return { + updateInfo: { + version: '0.15.0', + }, + }; + }, + downloadUpdate: async () => [], + quitAndInstall: () => {}, + }; + const appUpdater = createElectronAppUpdater({ + currentVersion: '0.14.0', + isPackaged: true, + updater, + log: () => {}, + configureHttpExecutor: () => httpExecutor, + disableDifferentialDownload: true, + }); + + const result = await appUpdater.checkForUpdates('stable'); + + assert.equal(result.available, true); + assert.equal(executorDuringCheck, httpExecutor); + assert.equal(differentialDownloadDuringCheck, true); +}); + test('resolveMacAppBundlePath resolves packaged macOS executable path', () => { assert.equal( resolveMacAppBundlePath('/Applications/SubMiner.app/Contents/MacOS/SubMiner'), @@ -185,6 +225,25 @@ test('mac native updater is unsupported for ad-hoc signed app bundles', async () assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']); }); +test('mac native updater is unsupported outside Applications folders before signature probing', async () => { + const logged: string[] = []; + const supported = await isNativeUpdaterSupported({ + platform: 'darwin', + isPackaged: true, + execPath: '/Users/tester/build/SubMiner.app/Contents/MacOS/SubMiner', + homeDir: '/Users/tester', + readCodeSignature: () => { + throw new Error('signature should not be read'); + }, + log: (message) => logged.push(message), + }); + + assert.equal(supported, false); + assert.deepEqual(logged, [ + 'Skipping native macOS updater because the app is not installed in an Applications folder.', + ]); +}); + test('mac native updater supports Developer ID signed packaged app bundles', async () => { const logged: string[] = []; const supported = await isNativeUpdaterSupported({ diff --git a/src/main/runtime/update/app-updater.ts b/src/main/runtime/update/app-updater.ts index b00fa090..64ce5224 100644 --- a/src/main/runtime/update/app-updater.ts +++ b/src/main/runtime/update/app-updater.ts @@ -1,5 +1,7 @@ import { realpathSync } from 'node:fs'; import { execFile } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; import { promisify } from 'node:util'; import { autoUpdater as electronAutoUpdater } from 'electron-updater'; import type { UpdateChannel } from '../../../types/config'; @@ -34,11 +36,16 @@ export interface ElectronAutoUpdaterLike { } | null>; downloadUpdate: () => Promise; quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void; + disableDifferentialDownload?: boolean; } const updaterErrorListeners = new WeakMap void>(); const execFileAsync = promisify(execFile); +type ElectronAutoUpdaterWithHttpExecutor = ElectronAutoUpdaterLike & { + httpExecutor?: unknown; +}; + export function resolveMacAppBundlePath(execPath: string): string | null { const marker = '.app/Contents/MacOS/'; const markerIndex = execPath.indexOf(marker); @@ -65,6 +72,25 @@ function realpathOrOriginal(filePath: string): string { } } +function isSameOrInsideDirectory(parentPath: string, candidatePath: string): boolean { + const relative = path.relative(parentPath, candidatePath); + return ( + relative === '' || + (relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative)) + ); +} + +export function isMacApplicationsFolderBundle( + appBundlePath: string, + homeDir: string = os.homedir(), +): boolean { + const resolvedBundlePath = path.resolve(appBundlePath); + return ( + isSameOrInsideDirectory('/Applications', resolvedBundlePath) || + isSameOrInsideDirectory(path.join(homeDir, 'Applications'), resolvedBundlePath) + ); +} + export function isKnownLinuxPackageManagedAppImage(appImagePath: string): boolean { return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage'; } @@ -74,6 +100,7 @@ export async function isNativeUpdaterSupported(options: { isPackaged: boolean; execPath: string; env?: NodeJS.ProcessEnv; + homeDir?: string; readCodeSignature?: (appBundlePath: string) => string | null | Promise; log?: (message: string) => void; }): Promise { @@ -100,6 +127,13 @@ export async function isNativeUpdaterSupported(options: { 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?.( @@ -157,6 +191,8 @@ export function createElectronAppUpdater(options: { log: (message: string) => void; getChannel?: () => UpdateChannel; isNativeUpdaterSupported?: () => boolean | Promise; + configureHttpExecutor?: () => unknown; + disableDifferentialDownload?: boolean; }) { const getChannel = options.getChannel ?? (() => 'stable' as const); const updater = configureAutoUpdater( @@ -164,6 +200,13 @@ export function createElectronAppUpdater(options: { options.log, getChannel(), ); + if (options.configureHttpExecutor) { + // electron-updater has no public executor hook; keep the macOS cURL override localized. + (updater as ElectronAutoUpdaterWithHttpExecutor).httpExecutor = options.configureHttpExecutor(); + } + if (options.disableDifferentialDownload !== undefined) { + updater.disableDifferentialDownload = options.disableDifferentialDownload; + } let nativeUpdaterSupported: Promise | null = null; async function getNativeUpdaterSupported(): Promise { diff --git a/src/main/runtime/update/curl-http-executor.test.ts b/src/main/runtime/update/curl-http-executor.test.ts new file mode 100644 index 00000000..11bc70f6 --- /dev/null +++ b/src/main/runtime/update/curl-http-executor.test.ts @@ -0,0 +1,144 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { createCurlHttpExecutor, type CurlExecFile } from './curl-http-executor'; + +test('curl HTTP executor requests updater metadata without Electron networking', async () => { + const calls: Array<{ file: string; args: readonly string[] }> = []; + const execFile: CurlExecFile = (file, args, _options, callback) => { + calls.push({ file, args }); + queueMicrotask(() => callback(null, 'metadata', '')); + return { kill: () => true }; + }; + const executor = createCurlHttpExecutor({ execFile, curlPath: '/usr/bin/curl' }); + + const result = await executor.request({ + protocol: 'https:', + hostname: 'api.github.com', + path: '/repos/ksyasuda/SubMiner/releases', + headers: { + Accept: 'application/vnd.github+json', + 'x-user-staging-id': 'abc', + }, + timeout: 120_000, + }); + + assert.equal(result, 'metadata'); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.file, '/usr/bin/curl'); + assert.deepEqual(calls[0]?.args, [ + '--fail', + '--location', + '--silent', + '--show-error', + '--connect-timeout', + '30', + '--max-time', + '120', + '--header', + 'Accept: application/vnd.github+json', + '--header', + 'x-user-staging-id: abc', + 'https://api.github.com/repos/ksyasuda/SubMiner/releases', + ]); +}); + +test('curl HTTP executor downloads updater assets to the requested destination', async () => { + const calls: Array<{ args: readonly string[] }> = []; + const execFile: CurlExecFile = (_file, args, _options, callback) => { + calls.push({ args }); + queueMicrotask(() => callback(null, Buffer.alloc(0), Buffer.alloc(0))); + return { kill: () => true }; + }; + const executor = createCurlHttpExecutor({ + execFile, + curlPath: '/usr/bin/curl', + mkdir: async () => undefined, + }); + + await executor.download( + new URL('https://github.com/ksyasuda/SubMiner/releases/download/v1/app.zip'), + '/tmp/subminer/update.zip', + { + headers: { 'User-Agent': 'SubMiner updater' }, + cancellationToken: { + createPromise: (callback) => + new Promise((resolve, reject) => callback(resolve, reject, () => {})), + }, + }, + ); + + assert.deepEqual(calls[0]?.args, [ + '--fail', + '--location', + '--silent', + '--show-error', + '--connect-timeout', + '30', + '--header', + 'User-Agent: SubMiner updater', + '--output', + '/tmp/subminer/update.zip', + 'https://github.com/ksyasuda/SubMiner/releases/download/v1/app.zip', + ]); +}); + +test('curl HTTP executor verifies downloaded updater asset hashes', async () => { + const data = Buffer.from('zip payload'); + const expectedSha512 = createHash('sha512').update(data).digest('base64'); + const execFile: CurlExecFile = (_file, _args, _options, callback) => { + queueMicrotask(() => callback(null, Buffer.alloc(0), Buffer.alloc(0))); + return { kill: () => true }; + }; + const executor = createCurlHttpExecutor({ + execFile, + curlPath: '/usr/bin/curl', + mkdir: async () => undefined, + readFile: async () => data, + }); + + await executor.download(new URL('https://example.test/update.zip'), '/tmp/subminer/update.zip', { + sha512: expectedSha512, + cancellationToken: { + createPromise: (callback) => + new Promise((resolve, reject) => callback(resolve, reject, () => {})), + }, + }); + + await assert.rejects( + () => + executor.download(new URL('https://example.test/update.zip'), '/tmp/subminer/update.zip', { + sha512: 'bad', + cancellationToken: { + createPromise: (callback) => + new Promise((resolve, reject) => callback(resolve, reject, () => {})), + }, + }), + /sha512 mismatch/, + ); +}); + +test('curl HTTP executor does not expose command arguments when stderr is empty', async () => { + const execFile: CurlExecFile = (_file, _args, _options, callback) => { + const error = new Error('--header Authorization: Bearer secret-token'); + Object.assign(error, { code: 'ENOENT' }); + queueMicrotask(() => callback(error, '', '')); + return { kill: () => true }; + }; + const executor = createCurlHttpExecutor({ execFile, curlPath: '/usr/bin/curl' }); + + await assert.rejects( + () => + executor.request({ + protocol: 'https:', + hostname: 'api.github.com', + path: '/repos/ksyasuda/SubMiner/releases', + }), + (error) => { + assert.ok(error instanceof Error); + assert.equal(error.message, 'curl failed (ENOENT)'); + assert.doesNotMatch(error.message, /secret-token|Authorization/); + return true; + }, + ); +}); diff --git a/src/main/runtime/update/curl-http-executor.ts b/src/main/runtime/update/curl-http-executor.ts new file mode 100644 index 00000000..4b18fe48 --- /dev/null +++ b/src/main/runtime/update/curl-http-executor.ts @@ -0,0 +1,212 @@ +import { execFile as defaultExecFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { RequestOptions, OutgoingHttpHeaders } from 'node:http'; + +export type CurlExecFile = ( + file: string, + args: readonly string[], + options: { + encoding: 'utf8' | 'buffer'; + maxBuffer?: number; + timeout?: number; + }, + callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void, +) => { kill: (signal?: NodeJS.Signals) => unknown }; + +type CancellationTokenLike = { + createPromise: ( + callback: ( + resolve: (value: T | PromiseLike) => void, + reject: (error: Error) => void, + onCancel: (callback: () => void) => void, + ) => void, + ) => Promise; +}; + +type CurlDownloadOptions = { + headers?: OutgoingHttpHeaders | null; + sha2?: string | null; + sha512?: string | null; + cancellationToken: CancellationTokenLike; +}; + +export type CurlHttpExecutor = { + request: ( + options: RequestOptions, + cancellationToken?: CancellationTokenLike, + data?: Record | null, + ) => Promise; + download: (url: URL, destination: string, options: CurlDownloadOptions) => Promise; + downloadToBuffer: (url: URL, options: CurlDownloadOptions) => Promise; +}; + +function requestOptionsToUrl(options: RequestOptions): string { + const protocol = options.protocol ?? 'https:'; + const hostname = options.hostname ?? options.host; + if (!hostname) throw new Error('Updater request is missing a hostname.'); + const port = options.port ? `:${options.port}` : ''; + const requestPath = options.path ?? '/'; + return `${protocol}//${hostname}${port}${requestPath}`; +} + +function addHeaderArgs( + args: string[], + headers: RequestOptions['headers'] | OutgoingHttpHeaders | null | undefined, +): void { + if (Array.isArray(headers)) { + for (let index = 0; index < headers.length; index += 2) { + const name = headers[index]; + const value = headers[index + 1]; + if (name !== undefined && value !== undefined) { + args.push('--header', `${name}: ${value}`); + } + } + return; + } + for (const [name, value] of Object.entries(headers ?? {})) { + if (value === undefined) continue; + const values = Array.isArray(value) ? value : [value]; + for (const item of values) { + args.push('--header', `${name}: ${String(item)}`); + } + } +} + +function buildBaseArgs(timeoutMs?: number): string[] { + const args = ['--fail', '--location', '--silent', '--show-error', '--connect-timeout', '30']; + if (typeof timeoutMs === 'number' && timeoutMs > 0) { + args.push('--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000)))); + } + return args; +} + +function runCurl(options: { + execFile: CurlExecFile; + curlPath: string; + args: readonly string[]; + encoding: 'utf8' | 'buffer'; + maxBuffer?: number; + timeout?: number; + cancellationToken?: CancellationTokenLike; +}): Promise { + const run = ( + resolve: (value: T) => void, + reject: (error: Error) => void, + onCancel: (callback: () => void) => void, + ) => { + const child = options.execFile( + options.curlPath, + options.args, + { + encoding: options.encoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + }, + (error, stdout, stderr) => { + if (error) { + const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr; + const errno = (error as NodeJS.ErrnoException).code; + const safeFallback = errno ? `curl failed (${errno})` : 'curl failed'; + reject(new Error(stderrMessage.trim() || safeFallback)); + return; + } + resolve(stdout as T); + }, + ); + onCancel(() => { + child.kill('SIGTERM'); + }); + }; + + if (options.cancellationToken) { + return options.cancellationToken.createPromise(run); + } + return new Promise((resolve, reject) => run(resolve, reject, () => {})); +} + +export function createCurlHttpExecutor( + options: { + execFile?: CurlExecFile; + curlPath?: string; + mkdir?: (targetPath: string) => Promise; + readFile?: (targetPath: string) => Promise; + } = {}, +): CurlHttpExecutor { + const execFile = options.execFile ?? (defaultExecFile as unknown as CurlExecFile); + const curlPath = options.curlPath ?? '/usr/bin/curl'; + const mkdir = + options.mkdir ?? ((targetPath: string) => fs.promises.mkdir(targetPath, { recursive: true })); + const readFile = options.readFile ?? ((targetPath: string) => fs.promises.readFile(targetPath)); + + async function verifyDownloadedFile(destination: string, downloadOptions: CurlDownloadOptions) { + if (!downloadOptions.sha512 && !downloadOptions.sha2) return; + const data = await readFile(destination); + if (downloadOptions.sha512) { + const actual = createHash('sha512').update(data).digest('base64'); + if (actual !== downloadOptions.sha512) { + throw new Error(`sha512 mismatch: expected ${downloadOptions.sha512}, got ${actual}`); + } + } + if (downloadOptions.sha2) { + const actual = createHash('sha256').update(data).digest('hex'); + if (actual !== downloadOptions.sha2.toLowerCase()) { + throw new Error(`sha2 mismatch: expected ${downloadOptions.sha2}, got ${actual}`); + } + } + } + + return { + async request(requestOptions, cancellationToken, data): Promise { + const args = buildBaseArgs(requestOptions.timeout); + addHeaderArgs(args, requestOptions.headers); + if (requestOptions.method && requestOptions.method !== 'GET') { + args.push('--request', requestOptions.method); + } + if (data) { + args.push('--data-binary', JSON.stringify(data)); + } + args.push(requestOptionsToUrl(requestOptions)); + const result = await runCurl({ + execFile, + curlPath, + args, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + timeout: requestOptions.timeout, + cancellationToken, + }); + return result.length === 0 ? null : result; + }, + async download(url, destination, downloadOptions): Promise { + await mkdir(path.dirname(destination)); + const args = buildBaseArgs(); + addHeaderArgs(args, downloadOptions.headers); + args.push('--output', destination, url.href); + await runCurl({ + execFile, + curlPath, + args, + encoding: 'buffer', + maxBuffer: 1024 * 1024, + cancellationToken: downloadOptions.cancellationToken, + }); + await verifyDownloadedFile(destination, downloadOptions); + return destination; + }, + async downloadToBuffer(url, downloadOptions): Promise { + const args = buildBaseArgs(); + addHeaderArgs(args, downloadOptions.headers); + args.push(url.href); + return await runCurl({ + execFile, + curlPath, + args, + encoding: 'buffer', + maxBuffer: 600 * 1024 * 1024, + cancellationToken: downloadOptions.cancellationToken, + }); + }, + }; +} diff --git a/src/main/runtime/update/fetch-adapter.test.ts b/src/main/runtime/update/fetch-adapter.test.ts new file mode 100644 index 00000000..da807fe1 --- /dev/null +++ b/src/main/runtime/update/fetch-adapter.test.ts @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElectronNetFetch } from './fetch-adapter'; +import type { FetchResponseLike } from './release-assets'; + +test('createElectronNetFetch delegates updater requests to Electron net.fetch', async () => { + const calls: Array<{ url: string; init?: Record }> = []; + const response: FetchResponseLike = { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ ok: true }), + text: async () => 'ok', + arrayBuffer: async () => new ArrayBuffer(0), + }; + + const fetch = createElectronNetFetch({ + fetch: async (url, init) => { + calls.push({ url, init }); + return response; + }, + }); + + const result = await fetch('https://api.github.com/repos/ksyasuda/SubMiner/releases', { + headers: { 'User-Agent': 'SubMiner updater' }, + }); + + assert.equal(result, response); + assert.deepEqual(calls, [ + { + url: 'https://api.github.com/repos/ksyasuda/SubMiner/releases', + init: { headers: { 'User-Agent': 'SubMiner updater' } }, + }, + ]); +}); diff --git a/src/main/runtime/update/fetch-adapter.ts b/src/main/runtime/update/fetch-adapter.ts new file mode 100644 index 00000000..95bf9293 --- /dev/null +++ b/src/main/runtime/update/fetch-adapter.ts @@ -0,0 +1,9 @@ +import type { FetchLike, FetchResponseLike } from './release-assets'; + +export interface ElectronNetFetchLike { + fetch: (url: string, init?: Record) => Promise; +} + +export function createElectronNetFetch(net: ElectronNetFetchLike): FetchLike { + return (url, init) => net.fetch(url, init); +} diff --git a/src/main/runtime/update/release-metadata-policy.test.ts b/src/main/runtime/update/release-metadata-policy.test.ts new file mode 100644 index 00000000..beb6c68f --- /dev/null +++ b/src/main/runtime/update/release-metadata-policy.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy'; + +test('macOS release metadata fetch is skipped only when native updater is unsupported', () => { + assert.equal( + shouldFetchReleaseMetadataForPlatform('darwin', { + available: false, + version: '0.14.0', + canUpdate: false, + }), + false, + ); + assert.equal( + shouldFetchReleaseMetadataForPlatform('darwin', { + available: false, + version: '0.14.0', + }), + true, + ); + assert.equal( + shouldFetchReleaseMetadataForPlatform('darwin', { + available: true, + version: '0.15.0', + canUpdate: true, + }), + true, + ); +}); + +test('non-macOS release metadata fetch is not gated by native updater support', () => { + assert.equal( + shouldFetchReleaseMetadataForPlatform('linux', { + available: false, + version: '0.14.0', + canUpdate: false, + }), + true, + ); + assert.equal( + shouldFetchReleaseMetadataForPlatform('win32', { + available: false, + version: '0.14.0', + canUpdate: false, + }), + true, + ); +}); diff --git a/src/main/runtime/update/release-metadata-policy.ts b/src/main/runtime/update/release-metadata-policy.ts new file mode 100644 index 00000000..ef9fc9b4 --- /dev/null +++ b/src/main/runtime/update/release-metadata-policy.ts @@ -0,0 +1,15 @@ +type AppUpdateMetadata = { + available: boolean; + version: string; + canUpdate?: boolean; +}; + +export function shouldFetchReleaseMetadataForPlatform( + platform: NodeJS.Platform, + appUpdate: AppUpdateMetadata, +): boolean { + if (platform !== 'darwin') { + return true; + } + return appUpdate.canUpdate !== false; +} diff --git a/src/main/runtime/update/update-service.test.ts b/src/main/runtime/update/update-service.test.ts index 0292067b..bd9a1225 100644 --- a/src/main/runtime/update/update-service.test.ts +++ b/src/main/runtime/update/update-service.test.ts @@ -151,11 +151,72 @@ test('manual update check does not prompt restart when only launcher updates', a const result = await service.checkForUpdates({ source: 'manual' }); assert.equal(result.status, 'update-available'); - assert.deepEqual(calls, [ - 'available-dialog:0.15.0', - 'launcher:stable', - 'manual-install:0.15.0', - ]); + assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable', 'manual-install:0.15.0']); +}); + +test('manual update check can skip release metadata after unsupported app updater', async () => { + const { deps, calls } = createDeps({ + checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }), + shouldFetchReleaseMetadata: ({ appUpdate }) => appUpdate.canUpdate !== false, + fetchLatestStableRelease: async () => { + calls.push('fetch-release'); + return { + tag_name: 'v0.15.0', + prerelease: false, + draft: false, + assets: [], + }; + }, + } as Partial); + const service = createUpdateService(deps); + + const result = await service.checkForUpdates({ source: 'manual' }); + + assert.equal(result.status, 'up-to-date'); + assert.deepEqual(calls, ['no-update:0.14.0']); +}); + +test('manual update check fetches release metadata after app metadata errors', async () => { + const { deps, calls } = createDeps({ + checkAppUpdate: async () => { + throw new Error('latest-mac.yml missing'); + }, + shouldFetchReleaseMetadata: ({ appUpdate }) => appUpdate.canUpdate !== false, + fetchLatestStableRelease: async () => { + calls.push('fetch-release'); + return { + tag_name: 'v0.15.0', + prerelease: false, + draft: false, + assets: [], + }; + }, + } as Partial); + const service = createUpdateService(deps); + + const result = await service.checkForUpdates({ source: 'manual' }); + + assert.equal(result.status, 'update-available'); + assert.deepEqual(calls, ['fetch-release', 'available-dialog:0.15.0']); +}); + +test('manual update check reports non-Error failures safely', async () => { + const { deps, calls } = createDeps({ + checkAppUpdate: async () => ({ available: true, version: '0.15.0' }), + showUpdateAvailableDialog: async (version) => { + calls.push(`available-dialog:${version}`); + return 'update'; + }, + downloadAppUpdate: async () => { + throw 'download rejected'; + }, + }); + const service = createUpdateService(deps); + + const result = await service.checkForUpdates({ source: 'manual' }); + + assert.deepEqual(result, { status: 'failed', error: 'download rejected' }); + assert.deepEqual(calls, ['available-dialog:0.15.0', 'failed:download rejected']); }); test('automatic update check skips inside configured interval', async () => { diff --git a/src/main/runtime/update/update-service.ts b/src/main/runtime/update/update-service.ts index 27bd833c..f6382fb9 100644 --- a/src/main/runtime/update/update-service.ts +++ b/src/main/runtime/update/update-service.ts @@ -30,15 +30,24 @@ export interface UpdateCheckResult { error?: string; } +type AppUpdateMetadata = { + available: boolean; + version: string; + canUpdate?: boolean; +}; + export interface UpdateServiceDeps { getConfig: () => Required; getCurrentVersion: () => string; now: () => number; readState: () => Promise; writeState: (state: UpdateState) => Promise; - checkAppUpdate: ( - channel: UpdateChannel, - ) => Promise<{ available: boolean; version: string; canUpdate?: boolean }>; + checkAppUpdate: (channel: UpdateChannel) => Promise; + shouldFetchReleaseMetadata?: (input: { + request: UpdateCheckRequest; + channel: UpdateChannel; + appUpdate: AppUpdateMetadata; + }) => boolean; fetchLatestStableRelease: (channel: UpdateChannel) => Promise; updateLauncher: ( launcherPath?: string, @@ -112,22 +121,23 @@ export function createUpdateService(deps: UpdateServiceDeps) { } try { - const [appUpdate, release] = await Promise.all([ - deps.checkAppUpdate(channel).catch((error) => { - if (isAutomatic) { - deps.log(`App update metadata check failed: ${summarizeError(error)}`); - } - return { - available: false, - version: deps.getCurrentVersion(), - canUpdate: false, - }; - }), - deps.fetchLatestStableRelease(channel).catch((error) => { - deps.log(`GitHub release update check failed: ${(error as Error).message}`); - return null; - }), - ]); + const appUpdate: AppUpdateMetadata = 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); @@ -181,7 +191,7 @@ export function createUpdateService(deps: UpdateServiceDeps) { } return { status: 'updated', version: latest.version }; } catch (error) { - const message = (error as Error).message; + const message = summarizeError(error); if (isAutomatic) { deps.log(`Automatic update check failed: ${message}`); } else { diff --git a/src/main/state.ts b/src/main/state.ts index af3408be..5771d981 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -151,6 +151,7 @@ export interface AppState { anilistSetupWindow: BrowserWindow | null; jellyfinSetupWindow: BrowserWindow | null; firstRunSetupWindow: BrowserWindow | null; + configSettingsWindow: BrowserWindow | null; yomitanParserReadyPromise: Promise | null; yomitanParserInitPromise: Promise | null; mpvClient: MpvIpcClient | null; @@ -235,6 +236,7 @@ export function createAppState(values: AppStateInitialValues): AppState { anilistSetupWindow: null, jellyfinSetupWindow: null, firstRunSetupWindow: null, + configSettingsWindow: null, yomitanParserReadyPromise: null, yomitanParserInitPromise: null, mpvClient: null, diff --git a/src/preload-settings.test.ts b/src/preload-settings.test.ts new file mode 100644 index 00000000..7f4ac860 --- /dev/null +++ b/src/preload-settings.test.ts @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +test('settings preload stays sandbox-compatible by avoiding local runtime imports', () => { + const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload-settings.ts'), 'utf8'); + + assert.doesNotMatch(source, /from\s+['"]\.\/shared\/ipc\/contracts(?:\.(?:js|ts))?['"]/); +}); diff --git a/src/preload-settings.ts b/src/preload-settings.ts new file mode 100644 index 00000000..c587fdd5 --- /dev/null +++ b/src/preload-settings.ts @@ -0,0 +1,25 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import type { + ConfigSettingsAPI, + ConfigSettingsPatch, + ConfigSettingsSaveResult, + ConfigSettingsSnapshot, +} from './types/settings'; + +const SETTINGS_IPC_CHANNELS = { + getSnapshot: 'config:get-settings-snapshot', + savePatch: 'config:save-settings-patch', + openFile: 'config:open-settings-file', + openWindow: 'config:open-settings-window', +} as const; + +const configSettingsAPI: ConfigSettingsAPI = { + getSnapshot: (): Promise => + ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getSnapshot), + savePatch: (patch: ConfigSettingsPatch): Promise => + ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.savePatch, patch), + openSettingsFile: (): Promise => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.openFile), + openSettingsWindow: (): Promise => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.openWindow), +}; + +contextBridge.exposeInMainWorld('configSettingsAPI', configSettingsAPI); diff --git a/src/settings/index.html b/src/settings/index.html new file mode 100644 index 00000000..28aa6c8b --- /dev/null +++ b/src/settings/index.html @@ -0,0 +1,47 @@ + + + + + + + SubMiner Configuration + + + +
+ +
+
+
+

Configuration

+
+
+
+ + + +
+
+ + +
+
+
+ + + diff --git a/src/settings/input-values.test.ts b/src/settings/input-values.test.ts new file mode 100644 index 00000000..f7a9140d --- /dev/null +++ b/src/settings/input-values.test.ts @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseOptionalNumberInputValue } from './input-values'; + +test('parseOptionalNumberInputValue treats empty input as unset', () => { + assert.deepEqual(parseOptionalNumberInputValue(''), { ok: true, value: undefined }); + assert.deepEqual(parseOptionalNumberInputValue(' '), { ok: true, value: undefined }); +}); + +test('parseOptionalNumberInputValue parses finite numeric input', () => { + assert.deepEqual(parseOptionalNumberInputValue('42'), { ok: true, value: 42 }); + assert.deepEqual(parseOptionalNumberInputValue(' 3.5 '), { ok: true, value: 3.5 }); +}); + +test('parseOptionalNumberInputValue rejects invalid numeric input', () => { + assert.deepEqual(parseOptionalNumberInputValue('abc'), { ok: false }); +}); diff --git a/src/settings/input-values.ts b/src/settings/input-values.ts new file mode 100644 index 00000000..9c84df4c --- /dev/null +++ b/src/settings/input-values.ts @@ -0,0 +1,20 @@ +export type OptionalNumberInputParseResult = + | { + ok: true; + value: number | undefined; + } + | { + ok: false; + }; + +export function parseOptionalNumberInputValue(value: string): OptionalNumberInputParseResult { + const raw = value.trim(); + if (raw.length === 0) { + return { ok: true, value: undefined }; + } + const next = Number(raw); + if (!Number.isFinite(next)) { + return { ok: false }; + } + return { ok: true, value: next }; +} diff --git a/src/settings/settings-model.test.ts b/src/settings/settings-model.test.ts new file mode 100644 index 00000000..08784866 --- /dev/null +++ b/src/settings/settings-model.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createSettingsDraft, + filterSettingsFields, + setDraftValue, + getDirtyOperations, +} from './settings-model'; +import type { ConfigSettingsField } from '../types/settings'; + +const fields: ConfigSettingsField[] = [ + { + id: 'subtitleStyle.autoPauseVideoOnHover', + label: 'Pause on subtitle hover', + description: 'Pause while hovering subtitles.', + configPath: 'subtitleStyle.autoPauseVideoOnHover', + category: 'viewing', + section: 'Playback pause behavior', + control: 'boolean', + defaultValue: true, + restartBehavior: 'hot-reload', + }, + { + id: 'ankiConnect.enabled', + label: 'Enable AnkiConnect', + description: 'Enable Anki integration.', + configPath: 'ankiConnect.enabled', + category: 'mining-anki', + section: 'Connection', + control: 'boolean', + defaultValue: true, + restartBehavior: 'restart', + }, +]; + +test('filterSettingsFields searches label, section, and config path', () => { + assert.deepEqual( + filterSettingsFields(fields, { category: 'viewing', query: 'hover' }).map( + (field) => field.configPath, + ), + ['subtitleStyle.autoPauseVideoOnHover'], + ); + assert.deepEqual(filterSettingsFields(fields, { category: 'viewing', query: 'anki' }), []); +}); + +test('settings draft tracks dirty set and emits save operations', () => { + const draft = createSettingsDraft({ + 'subtitleStyle.autoPauseVideoOnHover': true, + }); + + setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', false); + assert.deepEqual(getDirtyOperations(draft), [ + { + op: 'set', + path: 'subtitleStyle.autoPauseVideoOnHover', + value: false, + }, + ]); + + setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', true); + assert.deepEqual(getDirtyOperations(draft), []); +}); diff --git a/src/settings/settings-model.ts b/src/settings/settings-model.ts new file mode 100644 index 00000000..23969cd9 --- /dev/null +++ b/src/settings/settings-model.ts @@ -0,0 +1,95 @@ +import type { + ConfigSettingsCategory, + ConfigSettingsField, + ConfigSettingsPatchOperation, + ConfigSettingsSnapshotValue, +} from '../types/settings'; + +export interface SettingsFilter { + category: ConfigSettingsCategory; + query?: string; +} + +export interface SettingsDraft { + readonly initialValues: Record; + readonly values: Record; + readonly resetPaths: Set; +} + +function normalizeQuery(query: string | undefined): string { + return (query ?? '').trim().toLowerCase(); +} + +function valuesEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function filterSettingsFields( + fields: ConfigSettingsField[], + filter: SettingsFilter, +): ConfigSettingsField[] { + const query = normalizeQuery(filter.query); + return fields.filter((field) => { + if (field.category !== filter.category || field.legacyHidden) { + return false; + } + if (!query) { + return true; + } + const haystack = [ + field.label, + field.description, + field.configPath, + field.section, + field.enumValues?.join(' ') ?? '', + ] + .join(' ') + .toLowerCase(); + return haystack.includes(query); + }); +} + +export function createSettingsDraft( + values: Record, +): SettingsDraft { + return { + initialValues: structuredClone(values), + values: structuredClone(values), + resetPaths: new Set(), + }; +} + +export function setDraftValue( + draft: SettingsDraft, + path: string, + value: ConfigSettingsSnapshotValue, +): void { + draft.values[path] = value; + draft.resetPaths.delete(path); +} + +export function resetDraftPath(draft: SettingsDraft, path: string, defaultValue: unknown): void { + draft.values[path] = structuredClone(defaultValue); + draft.resetPaths.add(path); +} + +export function getDirtyOperations(draft: SettingsDraft): ConfigSettingsPatchOperation[] { + const operations: ConfigSettingsPatchOperation[] = []; + const paths = new Set([...Object.keys(draft.initialValues), ...Object.keys(draft.values)]); + + for (const path of [...paths].sort()) { + if (draft.resetPaths.has(path)) { + operations.push({ op: 'reset', path }); + continue; + } + if (!valuesEqual(draft.values[path], draft.initialValues[path])) { + operations.push({ + op: 'set', + path, + value: draft.values[path], + }); + } + } + + return operations; +} diff --git a/src/settings/settings.ts b/src/settings/settings.ts new file mode 100644 index 00000000..b30ae893 --- /dev/null +++ b/src/settings/settings.ts @@ -0,0 +1,452 @@ +import type { + ConfigSettingsAPI, + ConfigSettingsCategory, + ConfigSettingsField, + ConfigSettingsPatchOperation, + ConfigSettingsSnapshot, + ConfigSettingsSnapshotValue, +} from '../types/settings'; +import { parseOptionalNumberInputValue } from './input-values'; +import { + createSettingsDraft, + filterSettingsFields, + getDirtyOperations, + resetDraftPath, + setDraftValue, + type SettingsDraft, +} from './settings-model'; + +declare global { + interface Window { + configSettingsAPI: ConfigSettingsAPI; + } +} + +const CATEGORY_LABELS: Record = { + viewing: 'Viewing', + 'mining-anki': 'Mining & Anki', + 'playback-sources': 'Playback & Sources', + input: 'Input', + integrations: 'Integrations', + 'tracking-app': 'Tracking & App', + advanced: 'Advanced', +}; + +const CATEGORY_ORDER: ConfigSettingsCategory[] = [ + 'viewing', + 'mining-anki', + 'playback-sources', + 'input', + 'integrations', + 'tracking-app', + 'advanced', +]; + +const state: { + snapshot: ConfigSettingsSnapshot | null; + draft: SettingsDraft | null; + category: ConfigSettingsCategory; + query: string; + inputErrors: Map; +} = { + snapshot: null, + draft: null, + category: 'viewing', + query: '', + inputErrors: new Map(), +}; + +function getElement(id: string): T { + const element = document.getElementById(id); + if (!element) { + throw new Error(`Missing settings element: ${id}`); + } + return element as T; +} + +const dom = { + categoryNav: getElement('categoryNav'), + categoryTitle: getElement('categoryTitle'), + categoryMeta: getElement('categoryMeta'), + searchInput: getElement('searchInput'), + openFileButton: getElement('openFileButton'), + saveButton: getElement('saveButton'), + statusBanner: getElement('statusBanner'), + warningsPanel: getElement('warningsPanel'), + settingsContent: getElement('settingsContent'), +}; + +function isSecretSnapshotValue( + value: ConfigSettingsSnapshotValue, +): value is { configured: boolean } { + return Boolean(value && typeof value === 'object' && 'configured' in value); +} + +function setStatus(message: string, tone: 'info' | 'error' | 'success' = 'info'): void { + dom.statusBanner.textContent = message; + dom.statusBanner.className = `status-banner ${tone}`; +} + +function clearStatus(): void { + dom.statusBanner.textContent = ''; + dom.statusBanner.className = 'status-banner hidden'; +} + +function getDirtyCount(): number { + return state.draft ? getDirtyOperations(state.draft).length : 0; +} + +function syncSaveButton(): void { + const dirtyCount = getDirtyCount(); + dom.saveButton.disabled = dirtyCount === 0 || state.inputErrors.size > 0; + dom.saveButton.textContent = dirtyCount > 0 ? `Save ${dirtyCount}` : 'Save'; +} + +function createElement( + tagName: K, + className?: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tagName); + if (className) { + element.className = className; + } + return element; +} + +function createFieldMeta(field: ConfigSettingsField): HTMLElement { + const meta = createElement('div', 'field-meta'); + const path = createElement('code'); + path.textContent = field.configPath; + meta.append(path); + + const restart = createElement('span', `restart-chip ${field.restartBehavior}`); + restart.textContent = field.restartBehavior === 'hot-reload' ? 'Live' : 'Restart'; + meta.append(restart); + + if (field.advanced) { + const advanced = createElement('span', 'advanced-chip'); + advanced.textContent = 'Advanced'; + meta.append(advanced); + } + return meta; +} + +function valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue { + return state.draft?.values[field.configPath] ?? field.defaultValue; +} + +function setFieldError(path: string, message: string | null): void { + if (message) { + state.inputErrors.set(path, message); + } else { + state.inputErrors.delete(path); + } + syncSaveButton(); +} + +function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void { + if (!state.draft) return; + setDraftValue(state.draft, path, value); + syncSaveButton(); +} + +function renderJsonInput( + field: ConfigSettingsField, + value: ConfigSettingsSnapshotValue, +): HTMLElement { + const textarea = createElement('textarea', 'config-textarea') as HTMLTextAreaElement; + textarea.spellcheck = false; + textarea.value = JSON.stringify(value ?? {}, null, 2); + textarea.addEventListener('input', () => { + try { + updateDraft(field.configPath, JSON.parse(textarea.value)); + textarea.classList.remove('invalid'); + setFieldError(field.configPath, null); + } catch { + textarea.classList.add('invalid'); + setFieldError(field.configPath, 'Invalid JSON'); + } + }); + return textarea; +} + +function renderStringListInput( + field: ConfigSettingsField, + value: ConfigSettingsSnapshotValue, +): HTMLElement { + const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement; + textarea.spellcheck = false; + textarea.value = Array.isArray(value) ? value.join('\n') : ''; + textarea.addEventListener('input', () => { + updateDraft( + field.configPath, + textarea.value + .split('\n') + .map((entry) => entry.trim()) + .filter(Boolean), + ); + }); + return textarea; +} + +function renderControl(field: ConfigSettingsField): HTMLElement { + const value = valueForField(field); + + if (field.control === 'boolean') { + const label = createElement('label', 'switch-control'); + const input = createElement('input') as HTMLInputElement; + input.type = 'checkbox'; + input.checked = Boolean(value); + input.addEventListener('change', () => updateDraft(field.configPath, input.checked)); + const track = createElement('span', 'switch-track'); + label.append(input, track); + return label; + } + + if (field.control === 'number') { + const input = createElement('input', 'config-input') as HTMLInputElement; + input.type = 'number'; + input.value = typeof value === 'number' ? String(value) : ''; + input.addEventListener('input', () => { + const next = parseOptionalNumberInputValue(input.value); + if (next.ok) { + input.classList.remove('invalid'); + setFieldError(field.configPath, null); + updateDraft(field.configPath, next.value); + } else { + input.classList.add('invalid'); + setFieldError(field.configPath, 'Invalid number'); + } + }); + return input; + } + + if (field.control === 'select') { + const select = createElement('select', 'config-input') as HTMLSelectElement; + for (const enumValue of field.enumValues ?? []) { + const option = createElement('option') as HTMLOptionElement; + option.value = enumValue; + option.textContent = enumValue; + option.selected = enumValue === value; + select.append(option); + } + select.addEventListener('change', () => updateDraft(field.configPath, select.value)); + return select; + } + + if (field.control === 'string-list') { + return renderStringListInput(field, value); + } + + if (field.control === 'json') { + return renderJsonInput(field, value); + } + + if (field.control === 'textarea') { + const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement; + textarea.spellcheck = false; + textarea.value = typeof value === 'string' ? value : ''; + textarea.addEventListener('input', () => updateDraft(field.configPath, textarea.value)); + return textarea; + } + + const input = createElement('input', 'config-input') as HTMLInputElement; + input.type = field.control === 'secret' ? 'password' : field.control; + if (field.control === 'secret') { + input.placeholder = + isSecretSnapshotValue(value) && value.configured ? 'Configured' : 'Not configured'; + input.addEventListener('input', () => { + if (input.value.trim().length === 0) { + if (state.draft) { + setDraftValue(state.draft, field.configPath, state.draft.initialValues[field.configPath]); + } + syncSaveButton(); + return; + } + updateDraft(field.configPath, input.value); + }); + } else { + input.value = typeof value === 'string' ? value : ''; + input.addEventListener('input', () => updateDraft(field.configPath, input.value)); + } + return input; +} + +function renderWarnings(snapshot: ConfigSettingsSnapshot): void { + dom.warningsPanel.replaceChildren(); + if (snapshot.warnings.length === 0) { + dom.warningsPanel.className = 'warnings-panel hidden'; + return; + } + + const title = createElement('div', 'warnings-title'); + title.textContent = `${snapshot.warnings.length} validation warning${ + snapshot.warnings.length === 1 ? '' : 's' + }`; + dom.warningsPanel.append(title); + + for (const warning of snapshot.warnings.slice(0, 6)) { + const row = createElement('div', 'warning-row'); + const path = createElement('code'); + path.textContent = warning.path; + const message = createElement('span'); + message.textContent = warning.message; + row.append(path, message); + dom.warningsPanel.append(row); + } + dom.warningsPanel.className = 'warnings-panel'; +} + +function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void { + dom.categoryNav.replaceChildren(); + for (const category of CATEGORY_ORDER) { + const count = snapshot.fields.filter( + (field) => field.category === category && !field.legacyHidden, + ).length; + if (count === 0) continue; + const button = createElement('button', 'category-button') as HTMLButtonElement; + button.type = 'button'; + button.classList.toggle('active', state.category === category); + const label = createElement('span'); + label.textContent = CATEGORY_LABELS[category]; + const badge = createElement('strong'); + badge.textContent = String(count); + button.append(label, badge); + button.addEventListener('click', () => { + state.category = category; + render(); + }); + dom.categoryNav.append(button); + } +} + +function renderField(field: ConfigSettingsField): HTMLElement { + const row = createElement('article', 'field-row'); + const header = createElement('div', 'field-copy'); + const label = createElement('h3'); + label.textContent = field.label; + const description = createElement('p'); + description.textContent = field.description; + header.append(label, description, createFieldMeta(field)); + + const controlWrap = createElement('div', 'field-control'); + controlWrap.append(renderControl(field)); + const resetButton = createElement('button', 'reset-button') as HTMLButtonElement; + resetButton.type = 'button'; + resetButton.textContent = 'Reset'; + resetButton.addEventListener('click', () => { + if (!state.draft) return; + resetDraftPath(state.draft, field.configPath, field.defaultValue); + state.inputErrors.delete(field.configPath); + render(); + }); + controlWrap.append(resetButton); + row.append(header, controlWrap); + return row; +} + +function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void { + dom.settingsContent.replaceChildren(); + const fields = filterSettingsFields(snapshot.fields, { + category: state.category, + query: state.query, + }); + + dom.categoryTitle.textContent = CATEGORY_LABELS[state.category]; + dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`; + + if (fields.length === 0) { + const empty = createElement('div', 'empty-state'); + empty.textContent = 'No matching settings'; + dom.settingsContent.append(empty); + return; + } + + const sections = new Map(); + for (const field of fields) { + const sectionFields = sections.get(field.section) ?? []; + sectionFields.push(field); + sections.set(field.section, sectionFields); + } + + for (const [section, sectionFields] of sections) { + const sectionEl = createElement('section', 'settings-section'); + const title = createElement('h2'); + title.textContent = section; + sectionEl.append(title); + for (const field of sectionFields) { + sectionEl.append(renderField(field)); + } + dom.settingsContent.append(sectionEl); + } +} + +function render(): void { + const snapshot = state.snapshot; + if (!snapshot) return; + renderCategoryNav(snapshot); + renderWarnings(snapshot); + renderSettingsContent(snapshot); + syncSaveButton(); +} + +async function loadSnapshot(): Promise { + clearStatus(); + const snapshot = await window.configSettingsAPI.getSnapshot(); + state.snapshot = snapshot; + state.draft = createSettingsDraft(snapshot.values); + state.inputErrors.clear(); + render(); +} + +async function save(): Promise { + if (!state.draft) return; + const operations: ConfigSettingsPatchOperation[] = getDirtyOperations(state.draft); + if (operations.length === 0) return; + + dom.saveButton.disabled = true; + setStatus('Saving...', 'info'); + try { + const result = await window.configSettingsAPI.savePatch({ operations }); + if (!result.ok || !result.snapshot) { + const message = + result.error ?? + result.warnings?.map((warning) => `${warning.path}: ${warning.message}`).join('\n') ?? + 'Save failed'; + setStatus(message, 'error'); + return; + } + + state.snapshot = result.snapshot; + state.draft = createSettingsDraft(result.snapshot.values); + state.inputErrors.clear(); + const restartSections = result.restartRequiredSections ?? []; + if (restartSections.length > 0) { + setStatus(`Saved. Restart required: ${restartSections.join(', ')}`, 'info'); + } else if (result.hotReloadFields.length > 0) { + setStatus('Saved. Live settings applied.', 'success'); + } else { + setStatus('Saved.', 'success'); + } + render(); + } catch (error) { + setStatus(error instanceof Error ? error.message : 'Save failed', 'error'); + } finally { + syncSaveButton(); + } +} + +dom.searchInput.addEventListener('input', () => { + state.query = dom.searchInput.value; + render(); +}); +dom.saveButton.addEventListener('click', () => { + void save(); +}); +dom.openFileButton.addEventListener('click', () => { + void window.configSettingsAPI.openSettingsFile(); +}); + +void loadSnapshot().catch((error) => { + setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error'); +}); diff --git a/src/settings/style.css b/src/settings/style.css new file mode 100644 index 00000000..897d37b3 --- /dev/null +++ b/src/settings/style.css @@ -0,0 +1,655 @@ +@font-face { + font-family: 'M PLUS 1'; + src: url('./fonts/MPLUS1[wght].ttf') format('truetype'); + font-weight: 100 900; + font-display: swap; +} + +:root { + /* Catppuccin Macchiato */ + --ctp-rosewater: #f4dbd6; + --ctp-flamingo: #f0c6c6; + --ctp-pink: #f5bde6; + --ctp-mauve: #c6a0f6; + --ctp-red: #ed8796; + --ctp-maroon: #ee99a0; + --ctp-peach: #f5a97f; + --ctp-yellow: #eed49f; + --ctp-green: #a6da95; + --ctp-teal: #8bd5ca; + --ctp-sky: #91d7e3; + --ctp-sapphire: #7dc4e4; + --ctp-blue: #8aadf4; + --ctp-lavender: #b7bdf8; + --ctp-text: #cad3f5; + --ctp-subtext1: #b8c0e0; + --ctp-subtext0: #a5adcb; + --ctp-overlay2: #939ab7; + --ctp-overlay1: #8087a2; + --ctp-overlay0: #6e738d; + --ctp-surface2: #5b6078; + --ctp-surface1: #494d64; + --ctp-surface0: #363a4f; + --ctp-base: #24273a; + --ctp-mantle: #1e2030; + --ctp-crust: #181926; + + /* Semantic */ + --bg: var(--ctp-base); + --panel: rgba(36, 39, 58, 0.85); + --panel-elevated: rgba(54, 58, 79, 0.55); + --line: rgba(110, 115, 141, 0.28); + --line-soft: rgba(110, 115, 141, 0.14); + --text: var(--ctp-text); + --muted: var(--ctp-subtext0); + --faint: var(--ctp-overlay1); + --accent: var(--ctp-blue); + --accent-strong: var(--ctp-lavender); + --highlight: var(--ctp-mauve); + --danger: var(--ctp-red); + --ok: var(--ctp-green); + --warn: var(--ctp-peach); + --shadow: rgba(0, 0, 0, 0.42); +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: var(--ctp-base); + color: var(--text); + font-family: + 'M PLUS 1', 'Avenir Next', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', sans-serif; + letter-spacing: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +button { + font-family: inherit; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; + background-color: rgba(110, 115, 141, 0.35); +} + +::-webkit-scrollbar-thumb:hover { + background-color: rgba(138, 173, 244, 0.45); +} + +.settings-shell { + display: grid; + grid-template-columns: 244px minmax(0, 1fr); + width: 100%; + height: 100%; +} + +.settings-nav { + display: flex; + flex-direction: column; + gap: 18px; + padding: 22px 16px; + border-right: 1px solid var(--line); + background: var(--ctp-mantle); +} + +.brand-block { + padding: 6px 8px 14px; + border-bottom: 1px solid var(--line-soft); +} + +.brand-title { + font-size: 21px; + font-weight: 820; + color: var(--ctp-lavender); +} + +.brand-subtitle { + margin-top: 4px; + color: var(--ctp-overlay2); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.category-nav { + display: flex; + flex-direction: column; + gap: 4px; +} + +.category-button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 38px; + padding: 8px 11px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--muted); + text-align: left; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease; +} + +.category-button:hover { + border-color: var(--line-soft); + background: rgba(138, 173, 244, 0.06); + color: var(--text); +} + +.category-button.active { + border-color: rgba(138, 173, 244, 0.42); + background: rgba(138, 173, 244, 0.14); + color: var(--text); +} + +.category-button strong { + min-width: 24px; + padding: 2px 7px; + border-radius: 999px; + background: rgba(110, 115, 141, 0.2); + color: var(--ctp-subtext0); + font-size: 11px; + font-weight: 700; + text-align: center; +} + +.category-button.active strong { + background: rgba(138, 173, 244, 0.22); + color: var(--ctp-lavender); +} + +.settings-main { + min-width: 0; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.settings-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + min-height: 78px; + padding: 18px 24px; + border-bottom: 1px solid var(--line); + background: var(--ctp-mantle); +} + +.toolbar-title-block { + min-width: 0; +} + +h1 { + margin: 0; + font-size: 22px; + line-height: 1.15; + font-weight: 800; + color: var(--ctp-text); + letter-spacing: -0.01em; +} + +.toolbar-meta { + margin-top: 5px; + color: var(--ctp-overlay2); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.search-input, +.config-input, +.config-textarea { + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(24, 25, 38, 0.85); + color: var(--text); + outline: none; + transition: + border-color 140ms ease, + box-shadow 140ms ease, + background 140ms ease; +} + +.search-input::placeholder, +.config-input::placeholder, +.config-textarea::placeholder { + color: var(--ctp-overlay0); +} + +.search-input { + width: 210px; + height: 36px; + padding: 0 12px; +} + +.config-input { + width: min(320px, 100%); + min-height: 36px; + padding: 7px 10px; +} + +.config-textarea { + width: min(420px, 100%); + min-height: 138px; + padding: 9px 11px; + resize: vertical; + font-family: + 'JetBrains Mono', 'SF Mono', 'M PLUS 1', 'Avenir Next', ui-monospace, SFMono-Regular, Menlo, + monospace; + font-size: 12.5px; + line-height: 1.55; +} + +.config-textarea.compact { + min-height: 86px; +} + +.search-input:hover, +.config-input:hover, +.config-textarea:hover { + border-color: rgba(138, 173, 244, 0.32); +} + +.search-input:focus, +.config-input:focus, +.config-textarea:focus { + border-color: rgba(138, 173, 244, 0.65); + background: rgba(24, 25, 38, 0.95); + box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.15); +} + +select.config-input { + appearance: none; + padding-right: 32px; + background-image: url("data:image/svg+xml;utf8,"); + background-position: right 12px center; + background-repeat: no-repeat; +} + +select.config-input option { + background: var(--ctp-mantle); + color: var(--text); +} + +.invalid, +.invalid:focus { + border-color: rgba(237, 135, 150, 0.65); + box-shadow: 0 0 0 3px rgba(237, 135, 150, 0.12); +} + +.primary-button, +.secondary-button, +.reset-button { + height: 36px; + border-radius: 8px; + border: 1px solid var(--line); + cursor: pointer; + font-weight: 700; + font-size: 13px; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease, + transform 60ms ease; +} + +.primary-button:active, +.secondary-button:active, +.reset-button:active { + transform: translateY(1px); +} + +.primary-button { + min-width: 92px; + padding: 0 16px; + border-color: transparent; + background: var(--ctp-blue); + color: var(--ctp-crust); +} + +.primary-button:hover:not(:disabled) { + filter: brightness(1.06); +} + +.primary-button:disabled { + border-color: var(--line); + background: rgba(54, 58, 79, 0.55); + color: var(--ctp-overlay0); + box-shadow: none; + cursor: default; +} + +.secondary-button, +.reset-button { + padding: 0 13px; + background: rgba(54, 58, 79, 0.5); + color: var(--text); +} + +.secondary-button:hover, +.reset-button:hover { + border-color: rgba(138, 173, 244, 0.45); + background: rgba(73, 77, 100, 0.6); + color: var(--ctp-lavender); +} + +.reset-button { + height: 32px; + padding: 0 10px; + font-size: 12px; + color: var(--ctp-subtext0); +} + +.status-banner, +.warnings-panel { + margin: 14px 24px 0; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--ctp-surface0); +} + +.status-banner { + padding: 11px 14px; + white-space: pre-wrap; + font-size: 13px; + font-weight: 600; + color: var(--ctp-subtext1); +} + +.status-banner.success { + border-color: rgba(166, 218, 149, 0.45); + background: rgba(166, 218, 149, 0.1); + color: var(--ctp-green); +} + +.status-banner.error { + border-color: rgba(237, 135, 150, 0.55); + background: rgba(237, 135, 150, 0.1); + color: var(--ctp-red); +} + +.hidden { + display: none !important; +} + +.warnings-panel { + padding: 12px 14px; + border-color: rgba(238, 212, 159, 0.32); + background: rgba(238, 212, 159, 0.07); +} + +.warnings-title { + margin-bottom: 8px; + color: var(--ctp-yellow); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.warning-row { + display: grid; + grid-template-columns: minmax(120px, 220px) minmax(0, 1fr); + gap: 12px; + padding: 5px 0; + color: var(--ctp-subtext1); + font-size: 12px; + line-height: 1.5; +} + +code { + padding: 2px 6px; + border-radius: 5px; + background: rgba(24, 25, 38, 0.7); + border: 1px solid var(--line-soft); + color: var(--ctp-teal); + font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + font-weight: 500; +} + +.settings-content { + min-height: 0; + overflow: auto; + padding: 20px 24px 32px; +} + +.settings-section { + margin-bottom: 24px; + border-radius: 12px; + border: 1px solid var(--line-soft); + background: var(--ctp-mantle); + overflow: hidden; +} + +.settings-section h2 { + margin: 0; + padding: 12px 16px; + background: var(--ctp-crust); + border-bottom: 1px solid var(--line-soft); + color: var(--ctp-lavender); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 430px); + gap: 20px; + align-items: start; + padding: 16px 16px; + border-top: 1px solid var(--line-soft); +} + +.settings-section h2 + .field-row { + border-top: none; +} + +.field-copy h3 { + margin: 0 0 5px; + font-size: 14px; + font-weight: 700; + color: var(--ctp-text); + letter-spacing: -0.005em; +} + +.field-copy p { + max-width: 640px; + margin: 0; + color: var(--ctp-subtext0); + font-size: 12.5px; + line-height: 1.55; +} + +.field-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 10px; +} + +.restart-chip, +.advanced-chip { + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(54, 58, 79, 0.5); + color: var(--ctp-overlay2); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.restart-chip.hot-reload { + border-color: rgba(166, 218, 149, 0.42); + background: rgba(166, 218, 149, 0.1); + color: var(--ctp-green); +} + +.restart-chip.restart { + border-color: rgba(245, 169, 127, 0.42); + background: rgba(245, 169, 127, 0.1); + color: var(--ctp-peach); +} + +.advanced-chip { + border-color: rgba(198, 160, 246, 0.4); + background: rgba(198, 160, 246, 0.1); + color: var(--ctp-mauve); +} + +.field-control { + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 8px; + min-width: 0; +} + +.switch-control { + position: relative; + display: inline-flex; + width: 46px; + height: 26px; + flex-shrink: 0; +} + +.switch-control input { + position: absolute; + opacity: 0; + inset: 0; + cursor: pointer; +} + +.switch-track { + width: 100%; + height: 100%; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(24, 25, 38, 0.85); + cursor: pointer; + transition: + background 140ms ease, + border-color 140ms ease; +} + +.switch-track::after { + content: ''; + position: absolute; + top: 3px; + left: 4px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--ctp-overlay1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + transition: + transform 160ms cubic-bezier(0.4, 0, 0.2, 1), + background 140ms ease; +} + +.switch-control input:checked + .switch-track { + border-color: rgba(138, 173, 244, 0.6); + background: rgba(138, 173, 244, 0.3); +} + +.switch-control input:checked + .switch-track::after { + transform: translateX(20px); + background: var(--ctp-lavender); +} + +.switch-control input:focus-visible + .switch-track { + box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.22); +} + +.empty-state { + padding: 40px; + border: 1px dashed var(--line); + border-radius: 10px; + color: var(--ctp-overlay1); + text-align: center; + font-size: 13px; +} + +@media (max-width: 780px) { + .settings-shell { + grid-template-columns: 1fr; + } + + .settings-nav { + max-height: 200px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .category-nav { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .settings-toolbar, + .field-row, + .field-control { + display: flex; + flex-direction: column; + align-items: stretch; + } + + .toolbar-actions { + width: 100%; + flex-wrap: wrap; + } + + .search-input { + width: 100%; + } +} diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 8fab85ea..111191a8 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -97,6 +97,10 @@ export const IPC_CHANNELS = { statsGetMediaSessions: 'stats:get-media-sessions', statsGetMediaDailyRollups: 'stats:get-media-daily-rollups', statsGetMediaCover: 'stats:get-media-cover', + getConfigSettingsSnapshot: 'config:get-settings-snapshot', + saveConfigSettingsPatch: 'config:save-settings-patch', + openConfigSettingsFile: 'config:open-settings-file', + openConfigSettingsWindow: 'config:open-settings-window', }, event: { subtitleSet: 'subtitle:set', diff --git a/src/types.ts b/src/types.ts index 4d066f56..c529c97e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,4 +4,5 @@ export * from './types/integrations'; export * from './types/runtime'; export * from './types/runtime-options'; export * from './types/session-bindings'; +export * from './types/settings'; export * from './types/subtitle'; diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 00000000..c796fb4f --- /dev/null +++ b/src/types/settings.ts @@ -0,0 +1,80 @@ +import type { ConfigValidationWarning } from './config'; + +export type ConfigSettingsCategory = + | 'viewing' + | 'mining-anki' + | 'playback-sources' + | 'input' + | 'integrations' + | 'tracking-app' + | 'advanced'; + +export type ConfigSettingsControl = + | 'boolean' + | 'number' + | 'text' + | 'textarea' + | 'select' + | 'color' + | 'string-list' + | 'json' + | 'secret'; + +export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart'; + +export interface ConfigSettingsField { + id: string; + label: string; + description: string; + configPath: string; + category: ConfigSettingsCategory; + section: string; + control: ConfigSettingsControl; + defaultValue: unknown; + enumValues?: readonly string[]; + restartBehavior: ConfigSettingsRestartBehavior; + advanced?: boolean; + secret?: boolean; + legacyHidden?: boolean; +} + +export type ConfigSettingsSnapshotValue = unknown; + +export interface ConfigSettingsSnapshot { + configPath: string; + fields: ConfigSettingsField[]; + values: Record; + warnings: ConfigValidationWarning[]; +} + +export type ConfigSettingsPatchOperation = + | { + op: 'set'; + path: string; + value: unknown; + } + | { + op: 'reset'; + path: string; + }; + +export interface ConfigSettingsPatch { + operations: ConfigSettingsPatchOperation[]; +} + +export interface ConfigSettingsSaveResult { + ok: boolean; + snapshot?: ConfigSettingsSnapshot; + warnings?: ConfigValidationWarning[]; + error?: string; + hotReloadFields: string[]; + restartRequiredFields: string[]; + restartRequiredSections?: string[]; +} + +export interface ConfigSettingsAPI { + getSnapshot(): Promise; + savePatch(patch: ConfigSettingsPatch): Promise; + openSettingsFile(): Promise; + openSettingsWindow(): Promise; +} diff --git a/update-service.js b/update-service.js new file mode 100644 index 00000000..bb249851 --- /dev/null +++ b/update-service.js @@ -0,0 +1,172 @@ +'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