refactor: split main runtime wrappers into focused modules

This commit is contained in:
2026-02-19 19:08:53 -08:00
parent 1efc0f8650
commit aaa19a33c5
35 changed files with 2347 additions and 263 deletions

View File

@@ -6,7 +6,9 @@ Read first. Keep concise.
| ------------ | -------------- | ---------------------------------------------------- | --------- | ------------------------------------- | ---------------------- |
| `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` |
| `codex-config-validation-20260219T172015Z-iiyf` | `codex-config-validation` | `Find root cause of config validation error for ~/.config/SubMiner/config.jsonc` | `completed` | `docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md` | `2026-02-19T17:26:17Z` |
| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T00:47:26Z` |
| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T02:56:34Z` |
| `codex-anilist-deeplink-20260219T233926Z` | `anilist-deeplink` | `Fix external subminer:// AniList callback handling from browser` | `done` | `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md` | `2026-02-19T23:59:21Z` |
| `codex-texthooker-highlights-20260220T002354Z-927c` | `codex-texthooker-highlights` | `Add optional texthooker highlight toggles for known/n+1/frequency/JLPT` | `completed` | `docs/subagents/agents/codex-texthooker-highlights-20260220T002354Z-927c.md` | `2026-02-20T00:30:49Z` |
| `codex-texthooker-ui-playwright-20260220T003827Z-k3p9` | `codex-texthooker-ui-playwright` | `Run Playwright MCP smoke/regression checks for texthooker-ui changes` | `completed` | `docs/subagents/agents/codex-texthooker-ui-playwright-20260220T003827Z-k3p9.md` | `2026-02-20T00:42:09Z` |
| `codex-texthooker-color-ws-20260220T005844Z-r7m2` | `codex-texthooker-color-ws` | `Fix texthooker websocket payload so token highlight colors render` | `completed` | `docs/subagents/agents/codex-texthooker-color-ws-20260220T005844Z-r7m2.md` | `2026-02-20T01:01:00Z` |
| `codex-nplusone-pos1-20260220T012300Z-c5he` | `codex-nplusone-pos1` | `Fix N+1 false-negative when Yomitan functional tokens inflate unknown candidate count` | `completed` | `docs/subagents/agents/codex-nplusone-pos1-20260220T012300Z-c5he.md` | `2026-02-20T01:28:20Z` |

View File

@@ -9,6 +9,38 @@
## Current Work (newest first)
- [2026-02-20T02:56:34Z] progress: extracted subtitle copy/mine wrapper cluster into `src/main/runtime/mining-actions.ts` (`createHandleMultiCopyDigitHandler`, `createCopyCurrentSubtitleHandler`, `createHandleMineSentenceDigitHandler`) and rewired `handleMultiCopyDigit`, `copyCurrentSubtitle`, `handleMineSentenceDigit`.
- [2026-02-20T02:56:34Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mining-actions.test.js dist/main/runtime/anki-actions.test.js dist/main/runtime/overlay-shortcuts-lifecycle.test.js dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (95/95).
- [2026-02-20T02:55:17Z] progress: extracted Anki action wrappers into `src/main/runtime/anki-actions.ts` (`createUpdateLastCardFromClipboardHandler`, `createRefreshKnownWordCacheHandler`, `createTriggerFieldGroupingHandler`, `createMarkLastCardAsAudioCardHandler`, `createMineSentenceCardHandler`) and rewired `main.ts` handlers.
- [2026-02-20T02:55:17Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/anki-actions.test.js dist/main/runtime/overlay-shortcuts-lifecycle.test.js dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (92/92).
- [2026-02-20T02:52:42Z] progress: extracted overlay shortcut lifecycle wrappers into `src/main/runtime/overlay-shortcuts-lifecycle.ts` (`createRegisterOverlayShortcutsHandler`, `createUnregisterOverlayShortcutsHandler`, `createSyncOverlayShortcutsHandler`, `createRefreshOverlayShortcutsHandler`) and rewired `registerOverlayShortcuts`/`unregisterOverlayShortcuts`/`syncOverlayShortcuts`/`refreshOverlayShortcuts`.
- [2026-02-20T02:52:42Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-shortcuts-lifecycle.test.js dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (88/88).
- [2026-02-20T02:51:23Z] progress: extracted numeric shortcut session wrappers into `src/main/runtime/numeric-shortcut-session-handlers.ts` (`createCancelNumericShortcutSessionHandler`, `createStartNumericShortcutSessionHandler`) and rewired pending multi-copy/mine-sentence helper functions in `main.ts`.
- [2026-02-20T02:51:23Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (84/84).
- [2026-02-20T02:50:30Z] progress: extracted MPV OSD + log helpers into `src/main/runtime/mpv-osd-log.ts` (`createAppendToMpvLogHandler`, `createShowMpvOsdHandler`) and rewired `showMpvOsd` + `appendToMpvLog`.
- [2026-02-20T02:50:30Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (82/82).
- [2026-02-20T02:49:09Z] progress: extracted global shortcut helpers into `src/main/runtime/global-shortcuts.ts` (`createGetConfiguredShortcutsHandler`, `createRegisterGlobalShortcutsHandler`, `createRefreshGlobalAndOverlayShortcutsHandler`); rewired `getConfiguredShortcuts`, `registerGlobalShortcuts`, and `refreshGlobalAndOverlayShortcuts` in `main.ts`.
- [2026-02-20T02:49:09Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (79/79).
- [2026-02-20T02:45:53Z] progress: extracted Yomitan settings window opener wrapper into `src/main/runtime/yomitan-settings-opener.ts` (`createOpenYomitanSettingsHandler`); rewired `openYomitanSettings` in `main.ts`.
- [2026-02-20T02:45:53Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (76/76).
- [2026-02-20T02:44:31Z] progress: extracted overlay runtime bootstrap wrapper into `src/main/runtime/overlay-runtime-bootstrap.ts` (`createInitializeOverlayRuntimeHandler`) and rewired `initializeOverlayRuntime` orchestration; plus tray lifecycle extraction into `src/main/runtime/tray-lifecycle.ts` (`createEnsureTrayHandler`, `createDestroyTrayHandler`).
- [2026-02-20T02:44:31Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (74/74).
- [2026-02-20T02:41:26Z] progress: extracted tray lifecycle orchestration into `src/main/runtime/tray-lifecycle.ts` (`createEnsureTrayHandler`, `createDestroyTrayHandler`); rewired `ensureTray`/`destroyTray` in `main.ts` with existing behavior preserved.
- [2026-02-20T02:41:26Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (72/72).
- [2026-02-20T02:33:47Z] progress: extracted tray icon resolution + tray menu template construction into `src/main/runtime/tray-runtime.ts` (`resolveTrayIconPathRuntime`, `buildTrayMenuTemplateRuntime`); rewired `resolveTrayIconPath` and `buildTrayMenu` in `main.ts`.
- [2026-02-20T02:33:47Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (69/69).
- [2026-02-20T02:08:55Z] progress: extracted overlay bounds/window-level helpers into `src/main/runtime/overlay-window-layout.ts` (`createUpdateVisibleOverlayBoundsHandler`, `createUpdateInvisibleOverlayBoundsHandler`, `createEnsureOverlayWindowLevelHandler`, `createEnforceOverlayLayerOrderHandler`); rewired corresponding `main.ts` helpers.
- [2026-02-20T02:08:55Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (66/66).
- [2026-02-20T01:16:52Z] progress: extracted startup warmup orchestration into `src/main/runtime/startup-warmups.ts` (`createLaunchBackgroundWarmupTaskHandler`, `createStartBackgroundWarmupsHandler`); rewired `launchBackgroundWarmupTask` + `startBackgroundWarmups` in `main.ts`.
- [2026-02-20T01:16:52Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (62/62).
- [2026-02-20T01:15:36Z] progress: extracted subtitle render metrics state-update+broadcast slice into `src/main/runtime/mpv-subtitle-render-metrics.ts` (`createUpdateMpvSubtitleRenderMetricsHandler`); rewired `updateMpvSubtitleRenderMetrics` through a stable runtime handler.
- [2026-02-20T01:15:36Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (59/59).
- [2026-02-20T01:07:32Z] progress: extracted MPV runtime service construction/wiring from `createMpvClientRuntimeService` into `src/main/runtime/mpv-client-runtime-service.ts` (`createMpvClientRuntimeServiceFactory`); rewired `main.ts` to use factory with explicit options bundle.
- [2026-02-20T01:07:32Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (57/57).
- [2026-02-20T01:05:38Z] progress: extracted MPV client event binding orchestration from `bindMpvClientEventHandlers` into `src/main/runtime/mpv-client-event-bindings.ts` (`createBindMpvClientEventHandlers`, `createHandleMpvConnectionChangeHandler`, `createHandleMpvSubtitleTimingHandler`); rewired `main.ts` binding setup.
- [2026-02-20T01:05:38Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (56/56).
- [2026-02-20T00:59:30Z] progress: extracted CLI command context composition from `handleCliCommand` into `src/main/runtime/cli-command-context.ts`; rewired `main.ts` to build `cliContext` via factory before `handleCliCommandRuntimeServiceWithContext`.
- [2026-02-20T00:59:30Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (53/53).
- [2026-02-20T00:47:26Z] progress: extracted CLI runtime prechecks + initial arg orchestration into `src/main/runtime/cli-command-prechecks.ts` and `src/main/runtime/initial-args-handler.ts`; rewired `handleCliCommand` texthooker-only transition and `handleInitialArgs`.
- [2026-02-20T00:47:26Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (50/50).
- [2026-02-20T00:45:18Z] progress: extracted setup-window lifecycle/focus bookkeeping helpers into runtime modules (`createMaybeFocusExistingAnilistSetupWindowHandler`, `createHandleAnilistSetupWindowClosedHandler`, `createHandleAnilistSetupWindowOpenedHandler`, `createMaybeFocusExistingJellyfinSetupWindowHandler`, `createHandleJellyfinSetupWindowClosedHandler`, `createHandleJellyfinSetupWindowOpenedHandler`); rewired `openAnilistSetupWindow` + `openJellyfinSetupWindow`.
@@ -66,6 +98,38 @@
- `src/main/runtime/initial-args-handler.test.ts`
- `src/main/runtime/cli-command-prechecks.ts`
- `src/main/runtime/cli-command-prechecks.test.ts`
- `src/main/runtime/cli-command-context.ts`
- `src/main/runtime/cli-command-context.test.ts`
- `src/main/runtime/mpv-client-event-bindings.ts`
- `src/main/runtime/mpv-client-event-bindings.test.ts`
- `src/main/runtime/mpv-client-runtime-service.ts`
- `src/main/runtime/mpv-client-runtime-service.test.ts`
- `src/main/runtime/mpv-subtitle-render-metrics.ts`
- `src/main/runtime/mpv-subtitle-render-metrics.test.ts`
- `src/main/runtime/startup-warmups.ts`
- `src/main/runtime/startup-warmups.test.ts`
- `src/main/runtime/overlay-window-layout.ts`
- `src/main/runtime/overlay-window-layout.test.ts`
- `src/main/runtime/tray-runtime.ts`
- `src/main/runtime/tray-runtime.test.ts`
- `src/main/runtime/tray-lifecycle.ts`
- `src/main/runtime/tray-lifecycle.test.ts`
- `src/main/runtime/overlay-runtime-bootstrap.ts`
- `src/main/runtime/overlay-runtime-bootstrap.test.ts`
- `src/main/runtime/yomitan-settings-opener.ts`
- `src/main/runtime/yomitan-settings-opener.test.ts`
- `src/main/runtime/global-shortcuts.ts`
- `src/main/runtime/global-shortcuts.test.ts`
- `src/main/runtime/mpv-osd-log.ts`
- `src/main/runtime/mpv-osd-log.test.ts`
- `src/main/runtime/numeric-shortcut-session-handlers.ts`
- `src/main/runtime/numeric-shortcut-session-handlers.test.ts`
- `src/main/runtime/overlay-shortcuts-lifecycle.ts`
- `src/main/runtime/overlay-shortcuts-lifecycle.test.ts`
- `src/main/runtime/anki-actions.ts`
- `src/main/runtime/anki-actions.test.ts`
- `src/main/runtime/mining-actions.ts`
- `src/main/runtime/mining-actions.test.ts`
- `src/main/runtime/anilist-token-refresh.ts`
- `src/main/runtime/anilist-token-refresh.test.ts`
- `src/main/runtime/anilist-media-guess.ts`
@@ -97,4 +161,4 @@
## Next Step
- extract next `src/main.ts` CLI command context composition slice into helper factory + tests.
- extract next `src/main.ts` overlay visibility state wrappers (`setVisibleOverlayVisible`, `setInvisibleOverlayVisible`, `toggleVisibleOverlay`, `toggleInvisibleOverlay`) into runtime helper + tests.

View File

@@ -170,6 +170,44 @@ import {
createLaunchBackgroundWarmupTaskHandler,
createStartBackgroundWarmupsHandler,
} from './main/runtime/startup-warmups';
import {
createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler,
createUpdateInvisibleOverlayBoundsHandler,
createUpdateVisibleOverlayBoundsHandler,
} from './main/runtime/overlay-window-layout';
import { buildTrayMenuTemplateRuntime, resolveTrayIconPathRuntime } from './main/runtime/tray-runtime';
import { createDestroyTrayHandler, createEnsureTrayHandler } from './main/runtime/tray-lifecycle';
import { createInitializeOverlayRuntimeHandler } from './main/runtime/overlay-runtime-bootstrap';
import { createOpenYomitanSettingsHandler } from './main/runtime/yomitan-settings-opener';
import {
createGetConfiguredShortcutsHandler,
createRefreshGlobalAndOverlayShortcutsHandler,
createRegisterGlobalShortcutsHandler,
} from './main/runtime/global-shortcuts';
import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './main/runtime/mpv-osd-log';
import {
createCancelNumericShortcutSessionHandler,
createStartNumericShortcutSessionHandler,
} from './main/runtime/numeric-shortcut-session-handlers';
import {
createRefreshOverlayShortcutsHandler,
createRegisterOverlayShortcutsHandler,
createSyncOverlayShortcutsHandler,
createUnregisterOverlayShortcutsHandler,
} from './main/runtime/overlay-shortcuts-lifecycle';
import {
createMarkLastCardAsAudioCardHandler,
createMineSentenceCardHandler,
createRefreshKnownWordCacheHandler,
createTriggerFieldGroupingHandler,
createUpdateLastCardFromClipboardHandler,
} from './main/runtime/anki-actions';
import {
createCopyCurrentSubtitleHandler,
createHandleMineSentenceDigitHandler,
createHandleMultiCopyDigitHandler,
} from './main/runtime/mining-actions';
import {
buildRestartRequiredConfigMessage,
createConfigHotReloadAppliedHandler,
@@ -2146,27 +2184,33 @@ const startBackgroundWarmups = createStartBackgroundWarmupsHandler({
startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
});
function updateVisibleOverlayBounds(geometry: WindowGeometry): void {
overlayManager.setOverlayWindowBounds('visible', geometry);
}
function updateInvisibleOverlayBounds(geometry: WindowGeometry): void {
overlayManager.setOverlayWindowBounds('invisible', geometry);
}
function ensureOverlayWindowLevel(window: BrowserWindow): void {
ensureOverlayWindowLevelCore(window);
}
function enforceOverlayLayerOrder(): void {
enforceOverlayLayerOrderCore({
visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(),
invisibleOverlayVisible: overlayManager.getInvisibleOverlayVisible(),
mainWindow: overlayManager.getMainWindow(),
invisibleWindow: overlayManager.getInvisibleWindow(),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler({
setOverlayWindowBounds: (layer, geometry) => overlayManager.setOverlayWindowBounds(layer, geometry),
});
const updateInvisibleOverlayBounds = createUpdateInvisibleOverlayBoundsHandler({
setOverlayWindowBounds: (layer, geometry) => overlayManager.setOverlayWindowBounds(layer, geometry),
});
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler({
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
});
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler({
enforceOverlayLayerOrderCore: (params) =>
enforceOverlayLayerOrderCore({
visibleOverlayVisible: params.visibleOverlayVisible,
invisibleOverlayVisible: params.invisibleOverlayVisible,
mainWindow: params.mainWindow as BrowserWindow | null,
invisibleWindow: params.invisibleWindow as BrowserWindow | null,
ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as BrowserWindow),
}),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
});
}
async function loadYomitanExtension(): Promise<Extension | null> {
return loadYomitanExtensionCore({
@@ -2236,122 +2280,84 @@ function createInvisibleWindow(): BrowserWindow {
}
function resolveTrayIconPath(): string | null {
const iconNames =
process.platform === 'darwin'
? ['SubMinerTemplate.png', 'SubMinerTemplate@2x.png', 'SubMiner.png']
: ['SubMiner.png'];
const baseDirs = [
path.join(process.resourcesPath, 'assets'),
path.join(app.getAppPath(), 'assets'),
path.join(__dirname, '..', 'assets'),
path.join(__dirname, '..', '..', 'assets'),
];
const candidates = baseDirs.flatMap((baseDir) =>
iconNames.map((iconName) => path.join(baseDir, iconName)),
);
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
return resolveTrayIconPathRuntime({
platform: process.platform,
resourcesPath: process.resourcesPath,
appPath: app.getAppPath(),
dirname: __dirname,
joinPath: (...parts) => path.join(...parts),
fileExists: (candidate) => fs.existsSync(candidate),
});
}
function buildTrayMenu(): Menu {
return Menu.buildFromTemplate([
{
label: 'Open Overlay',
click: () => {
return Menu.buildFromTemplate(
buildTrayMenuTemplateRuntime({
openOverlay: () => {
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
}
setVisibleOverlayVisible(true);
},
},
{
label: 'Open Yomitan Settings',
click: () => {
openYomitanSettings: () => {
openYomitanSettings();
},
},
{
label: 'Open Runtime Options',
click: () => {
openRuntimeOptions: () => {
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
}
openRuntimeOptionsPalette();
},
},
{
label: 'Configure Jellyfin',
click: () => {
openJellyfinSetup: () => {
openJellyfinSetupWindow();
},
},
{
label: 'Configure AniList',
click: () => {
openAnilistSetup: () => {
openAnilistSetupWindow();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
quitApp: () => {
app.quit();
},
},
]);
}),
);
}
function ensureTray(): void {
if (appTray) {
appTray.setContextMenu(buildTrayMenu());
return;
}
const iconPath = resolveTrayIconPath();
let trayIcon = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
if (trayIcon.isEmpty()) {
logger.warn('Tray icon asset not found; using empty icon placeholder.');
}
if (process.platform === 'darwin' && !trayIcon.isEmpty()) {
// macOS status bar expects a small monochrome-like template icon.
// Feeding the full-size app icon can produce oversized/non-interactive items.
trayIcon = trayIcon.resize({ width: 18, height: 18, quality: 'best' });
trayIcon.setTemplateImage(true);
}
if (process.platform === 'linux' && !trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 20, height: 20 });
}
appTray = new Tray(trayIcon);
appTray.setToolTip(TRAY_TOOLTIP);
appTray.setContextMenu(buildTrayMenu());
appTray.on('click', () => {
createEnsureTrayHandler({
getTray: () => appTray,
setTray: (tray) => {
appTray = tray as Tray | null;
},
buildTrayMenu: () => buildTrayMenu(),
resolveTrayIconPath: () => resolveTrayIconPath(),
createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath),
createEmptyImage: () => nativeImage.createEmpty(),
createTray: (icon) => new Tray(icon as never),
trayTooltip: TRAY_TOOLTIP,
platform: process.platform,
logWarn: (message) => logger.warn(message),
ensureOverlayVisibleFromTrayClick: () => {
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
}
setVisibleOverlayVisible(true);
});
},
})();
}
function destroyTray(): void {
if (!appTray) {
return;
}
appTray.destroy();
appTray = null;
createDestroyTrayHandler({
getTray: () => appTray,
setTray: (tray) => {
appTray = tray as Tray | null;
},
})();
}
function initializeOverlayRuntime(): void {
if (appState.overlayRuntimeInitialized) {
return;
}
const result = initializeOverlayRuntimeCore({
createInitializeOverlayRuntimeHandler({
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options),
buildOptions: () => ({
backendOverride: appState.backendOverride,
getInitialInvisibleOverlayVisibility: () =>
configDerivedRuntime.getInitialInvisibleOverlayVisibility(),
@@ -2394,50 +2400,68 @@ function initializeOverlayRuntime(): void {
showDesktopNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
});
overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
appState.overlayRuntimeInitialized = true;
startBackgroundWarmups();
}),
setInvisibleOverlayVisible: (visible) => {
overlayManager.setInvisibleOverlayVisible(visible);
},
setOverlayRuntimeInitialized: (initialized) => {
appState.overlayRuntimeInitialized = initialized;
},
startBackgroundWarmups: () => startBackgroundWarmups(),
})();
}
function openYomitanSettings(): void {
void (async () => {
const extension = await ensureYomitanExtensionLoaded();
if (!extension) {
logger.warn('Unable to open Yomitan settings: extension failed to load.');
return;
createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => {
openYomitanSettingsWindow({
yomitanExt: yomitanExt as Extension,
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
setWindow: (window) => setWindow(window as BrowserWindow | null),
});
},
getExistingWindow: () => appState.yomitanSettingsWindow,
setWindow: (window) => {
appState.yomitanSettingsWindow = window as BrowserWindow | null;
},
logWarn: (message) => logger.warn(message),
logError: (message, error) => logger.error(message, error),
})();
}
openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: () => appState.yomitanSettingsWindow,
setWindow: (window: BrowserWindow | null) => {
appState.yomitanSettingsWindow = window;
},
const getConfiguredShortcutsHandler = createGetConfiguredShortcutsHandler({
getResolvedConfig: () => getResolvedConfig(),
defaultConfig: DEFAULT_CONFIG,
resolveConfiguredShortcuts,
});
})().catch((error) => {
logger.error('Failed to open Yomitan settings window.', error);
});
}
function registerGlobalShortcuts(): void {
registerGlobalShortcutsCore({
shortcuts: getConfiguredShortcuts(),
const registerGlobalShortcutsHandler = createRegisterGlobalShortcutsHandler({
getConfiguredShortcuts: () => getConfiguredShortcuts(),
registerGlobalShortcutsCore,
onToggleVisibleOverlay: () => toggleVisibleOverlay(),
onToggleInvisibleOverlay: () => toggleInvisibleOverlay(),
onOpenYomitanSettings: () => openYomitanSettings(),
isDev,
getMainWindow: () => overlayManager.getMainWindow(),
});
const refreshGlobalAndOverlayShortcutsHandler = createRefreshGlobalAndOverlayShortcutsHandler({
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
registerGlobalShortcuts: () => registerGlobalShortcuts(),
syncOverlayShortcuts: () => syncOverlayShortcuts(),
});
function registerGlobalShortcuts(): void {
registerGlobalShortcutsHandler();
}
function refreshGlobalAndOverlayShortcuts(): void {
globalShortcut.unregisterAll();
registerGlobalShortcuts();
syncOverlayShortcuts();
refreshGlobalAndOverlayShortcutsHandler();
}
function getConfiguredShortcuts() {
return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG);
return getConfiguredShortcutsHandler();
}
function cycleSecondarySubMode(): void {
@@ -2457,22 +2481,27 @@ function cycleSecondarySubMode(): void {
});
}
function showMpvOsd(text: string): void {
appendToMpvLog(`[OSD] ${text}`);
showMpvOsdRuntime(appState.mpvClient, text, (line) => {
logger.info(line);
const appendToMpvLogHandler = createAppendToMpvLogHandler({
logPath: DEFAULT_MPV_LOG_PATH,
dirname: (targetPath) => path.dirname(targetPath),
mkdirSync: (targetPath, options) => fs.mkdirSync(targetPath, options),
appendFileSync: (targetPath, data, options) => fs.appendFileSync(targetPath, data, options),
now: () => new Date(),
});
const showMpvOsdHandler = createShowMpvOsdHandler({
appendToMpvLog: (message) => appendToMpvLog(message),
showMpvOsdRuntime,
getMpvClient: () => appState.mpvClient,
logInfo: (line) => logger.info(line),
});
function showMpvOsd(text: string): void {
showMpvOsdHandler(text);
}
function appendToMpvLog(message: string): void {
try {
fs.mkdirSync(path.dirname(DEFAULT_MPV_LOG_PATH), { recursive: true });
fs.appendFileSync(DEFAULT_MPV_LOG_PATH, `[${new Date().toISOString()}] ${message}\n`, {
encoding: 'utf8',
});
} catch {
// best-effort logging
}
appendToMpvLogHandler(message);
}
const numericShortcutRuntime = createNumericShortcutRuntime({
@@ -2483,18 +2512,11 @@ const numericShortcutRuntime = createNumericShortcutRuntime({
});
const multiCopySession = numericShortcutRuntime.createSession();
const mineSentenceSession = numericShortcutRuntime.createSession();
async function triggerSubsyncFromConfig(): Promise<void> {
await subsyncRuntime.triggerFromConfig();
}
function cancelPendingMultiCopy(): void {
multiCopySession.cancel();
}
function startPendingMultiCopy(timeoutMs: number): void {
multiCopySession.start({
timeoutMs,
const cancelPendingMultiCopyHandler = createCancelNumericShortcutSessionHandler({
session: multiCopySession,
});
const startPendingMultiCopyHandler = createStartNumericShortcutSessionHandler({
session: multiCopySession,
onDigit: (count) => handleMultiCopyDigit(count),
messages: {
prompt: 'Copy how many lines? Press 1-9 (Esc to cancel)',
@@ -2502,72 +2524,11 @@ function startPendingMultiCopy(timeoutMs: number): void {
cancelled: 'Cancelled',
},
});
}
function handleMultiCopyDigit(count: number): void {
handleMultiCopyDigitCore(count, {
subtitleTimingTracker: appState.subtitleTimingTracker,
writeClipboardText: (text) => clipboard.writeText(text),
showMpvOsd: (text) => showMpvOsd(text),
const cancelPendingMineSentenceMultipleHandler = createCancelNumericShortcutSessionHandler({
session: mineSentenceSession,
});
}
function copyCurrentSubtitle(): void {
copyCurrentSubtitleCore({
subtitleTimingTracker: appState.subtitleTimingTracker,
writeClipboardText: (text) => clipboard.writeText(text),
showMpvOsd: (text) => showMpvOsd(text),
});
}
async function updateLastCardFromClipboard(): Promise<void> {
await updateLastCardFromClipboardCore({
ankiIntegration: appState.ankiIntegration,
readClipboardText: () => clipboard.readText(),
showMpvOsd: (text) => showMpvOsd(text),
});
}
async function refreshKnownWordCache(): Promise<void> {
if (!appState.ankiIntegration) {
throw new Error('AnkiConnect integration not enabled');
}
await appState.ankiIntegration.refreshKnownWordCache();
}
async function triggerFieldGrouping(): Promise<void> {
await triggerFieldGroupingCore({
ankiIntegration: appState.ankiIntegration,
showMpvOsd: (text) => showMpvOsd(text),
});
}
async function markLastCardAsAudioCard(): Promise<void> {
await markLastCardAsAudioCardCore({
ankiIntegration: appState.ankiIntegration,
showMpvOsd: (text) => showMpvOsd(text),
});
}
async function mineSentenceCard(): Promise<void> {
const created = await mineSentenceCardCore({
ankiIntegration: appState.ankiIntegration,
mpvClient: appState.mpvClient,
showMpvOsd: (text) => showMpvOsd(text),
});
if (created) {
appState.immersionTracker?.recordCardsMined(1);
}
}
function cancelPendingMineSentenceMultiple(): void {
mineSentenceSession.cancel();
}
function startPendingMineSentenceMultiple(timeoutMs: number): void {
mineSentenceSession.start({
timeoutMs,
const startPendingMineSentenceMultipleHandler = createStartNumericShortcutSessionHandler({
session: mineSentenceSession,
onDigit: (count) => handleMineSentenceDigit(count),
messages: {
prompt: 'Mine how many lines? Press 1-9 (Esc to cancel)',
@@ -2575,12 +2536,87 @@ function startPendingMineSentenceMultiple(timeoutMs: number): void {
cancelled: 'Cancelled',
},
});
const registerOverlayShortcutsHandler = createRegisterOverlayShortcutsHandler({
overlayShortcutsRuntime,
});
const unregisterOverlayShortcutsHandler = createUnregisterOverlayShortcutsHandler({
overlayShortcutsRuntime,
});
const syncOverlayShortcutsHandler = createSyncOverlayShortcutsHandler({
overlayShortcutsRuntime,
});
const refreshOverlayShortcutsHandler = createRefreshOverlayShortcutsHandler({
overlayShortcutsRuntime,
});
async function triggerSubsyncFromConfig(): Promise<void> {
await subsyncRuntime.triggerFromConfig();
}
function handleMineSentenceDigit(count: number): void {
handleMineSentenceDigitCore(count, {
subtitleTimingTracker: appState.subtitleTimingTracker,
ankiIntegration: appState.ankiIntegration,
function cancelPendingMultiCopy(): void {
cancelPendingMultiCopyHandler();
}
function startPendingMultiCopy(timeoutMs: number): void {
startPendingMultiCopyHandler(timeoutMs);
}
function handleMultiCopyDigit(count: number): void {
handleMultiCopyDigitHandler(count);
}
function copyCurrentSubtitle(): void {
copyCurrentSubtitleHandler();
}
const updateLastCardFromClipboardHandler = createUpdateLastCardFromClipboardHandler({
getAnkiIntegration: () => appState.ankiIntegration,
readClipboardText: () => clipboard.readText(),
showMpvOsd: (text) => showMpvOsd(text),
updateLastCardFromClipboardCore,
});
const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler({
getAnkiIntegration: () => appState.ankiIntegration,
missingIntegrationMessage: 'AnkiConnect integration not enabled',
});
const triggerFieldGroupingHandler = createTriggerFieldGroupingHandler({
getAnkiIntegration: () => appState.ankiIntegration,
showMpvOsd: (text) => showMpvOsd(text),
triggerFieldGroupingCore,
});
const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler({
getAnkiIntegration: () => appState.ankiIntegration,
showMpvOsd: (text) => showMpvOsd(text),
markLastCardAsAudioCardCore,
});
const mineSentenceCardHandler = createMineSentenceCardHandler({
getAnkiIntegration: () => appState.ankiIntegration,
getMpvClient: () => appState.mpvClient,
showMpvOsd: (text) => showMpvOsd(text),
mineSentenceCardCore,
recordCardsMined: (count) => {
appState.immersionTracker?.recordCardsMined(count);
},
});
const handleMultiCopyDigitHandler = createHandleMultiCopyDigitHandler({
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
writeClipboardText: (text) => clipboard.writeText(text),
showMpvOsd: (text) => showMpvOsd(text),
handleMultiCopyDigitCore,
});
const copyCurrentSubtitleHandler = createCopyCurrentSubtitleHandler({
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
writeClipboardText: (text) => clipboard.writeText(text),
showMpvOsd: (text) => showMpvOsd(text),
copyCurrentSubtitleCore,
});
const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler({
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
getAnkiIntegration: () => appState.ankiIntegration,
getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined,
showMpvOsd: (text) => showMpvOsd(text),
logError: (message, err) => {
@@ -2589,22 +2625,54 @@ function handleMineSentenceDigit(count: number): void {
onCardsMined: (cards) => {
appState.immersionTracker?.recordCardsMined(cards);
},
handleMineSentenceDigitCore,
});
async function updateLastCardFromClipboard(): Promise<void> {
await updateLastCardFromClipboardHandler();
}
async function refreshKnownWordCache(): Promise<void> {
await refreshKnownWordCacheHandler();
}
async function triggerFieldGrouping(): Promise<void> {
await triggerFieldGroupingHandler();
}
async function markLastCardAsAudioCard(): Promise<void> {
await markLastCardAsAudioCardHandler();
}
async function mineSentenceCard(): Promise<void> {
await mineSentenceCardHandler();
}
function cancelPendingMineSentenceMultiple(): void {
cancelPendingMineSentenceMultipleHandler();
}
function startPendingMineSentenceMultiple(timeoutMs: number): void {
startPendingMineSentenceMultipleHandler(timeoutMs);
}
function handleMineSentenceDigit(count: number): void {
handleMineSentenceDigitHandler(count);
}
function registerOverlayShortcuts(): void {
overlayShortcutsRuntime.registerOverlayShortcuts();
registerOverlayShortcutsHandler();
}
function unregisterOverlayShortcuts(): void {
overlayShortcutsRuntime.unregisterOverlayShortcuts();
unregisterOverlayShortcutsHandler();
}
function syncOverlayShortcuts(): void {
overlayShortcutsRuntime.syncOverlayShortcuts();
syncOverlayShortcutsHandler();
}
function refreshOverlayShortcuts(): void {
overlayShortcutsRuntime.refreshOverlayShortcuts();
refreshOverlayShortcutsHandler();
}
function setVisibleOverlayVisible(visible: boolean): void {

View File

@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createMarkLastCardAsAudioCardHandler,
createMineSentenceCardHandler,
createRefreshKnownWordCacheHandler,
createTriggerFieldGroupingHandler,
createUpdateLastCardFromClipboardHandler,
} from './anki-actions';
test('update last card handler forwards integration/clipboard/osd deps', async () => {
const calls: string[] = [];
const integration = {};
const updateLastCard = createUpdateLastCardFromClipboardHandler({
getAnkiIntegration: () => integration,
readClipboardText: () => 'clipboard-value',
showMpvOsd: (text) => calls.push(`osd:${text}`),
updateLastCardFromClipboardCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
assert.equal(options.readClipboardText(), 'clipboard-value');
options.showMpvOsd('ok');
calls.push('core');
},
});
await updateLastCard();
assert.deepEqual(calls, ['osd:ok', 'core']);
});
test('refresh known word cache handler throws when Anki integration missing', async () => {
const refresh = createRefreshKnownWordCacheHandler({
getAnkiIntegration: () => null,
missingIntegrationMessage: 'AnkiConnect integration not enabled',
});
await assert.rejects(() => refresh(), /AnkiConnect integration not enabled/);
});
test('trigger and mark handlers delegate to core services', async () => {
const calls: string[] = [];
const integration = {};
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
getAnkiIntegration: () => integration,
showMpvOsd: (text) => calls.push(`osd:${text}`),
triggerFieldGroupingCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
options.showMpvOsd('group');
calls.push('group-core');
},
});
const markAudio = createMarkLastCardAsAudioCardHandler({
getAnkiIntegration: () => integration,
showMpvOsd: (text) => calls.push(`osd:${text}`),
markLastCardAsAudioCardCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
options.showMpvOsd('mark');
calls.push('mark-core');
},
});
await triggerFieldGrouping();
await markAudio();
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
});
test('mine sentence handler records mined cards only when core returns true', async () => {
const calls: string[] = [];
const integration = {};
const mpvClient = {};
let created = false;
const mineSentenceCard = createMineSentenceCardHandler({
getAnkiIntegration: () => integration,
getMpvClient: () => mpvClient,
showMpvOsd: (text) => calls.push(`osd:${text}`),
mineSentenceCardCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
assert.equal(options.mpvClient, mpvClient);
options.showMpvOsd('mine');
return created;
},
recordCardsMined: (count) => calls.push(`cards:${count}`),
});
created = false;
await mineSentenceCard();
created = true;
await mineSentenceCard();
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
});

View File

@@ -0,0 +1,90 @@
type AnkiIntegrationLike = {
refreshKnownWordCache: () => Promise<void>;
};
export function createUpdateLastCardFromClipboardHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
updateLastCardFromClipboardCore: (options: {
ankiIntegration: TAnki;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return async (): Promise<void> => {
await deps.updateLastCardFromClipboardCore({
ankiIntegration: deps.getAnkiIntegration(),
readClipboardText: deps.readClipboardText,
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createRefreshKnownWordCacheHandler(deps: {
getAnkiIntegration: () => AnkiIntegrationLike | null;
missingIntegrationMessage: string;
}) {
return async (): Promise<void> => {
const anki = deps.getAnkiIntegration();
if (!anki) {
throw new Error(deps.missingIntegrationMessage);
}
await anki.refreshKnownWordCache();
};
}
export function createTriggerFieldGroupingHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
showMpvOsd: (text: string) => void;
triggerFieldGroupingCore: (options: {
ankiIntegration: TAnki;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return async (): Promise<void> => {
await deps.triggerFieldGroupingCore({
ankiIntegration: deps.getAnkiIntegration(),
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createMarkLastCardAsAudioCardHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
showMpvOsd: (text: string) => void;
markLastCardAsAudioCardCore: (options: {
ankiIntegration: TAnki;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return async (): Promise<void> => {
await deps.markLastCardAsAudioCardCore({
ankiIntegration: deps.getAnkiIntegration(),
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
getAnkiIntegration: () => TAnki;
getMpvClient: () => TMpv;
showMpvOsd: (text: string) => void;
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number) => void;
}) {
return async (): Promise<void> => {
const created = await deps.mineSentenceCardCore({
ankiIntegration: deps.getAnkiIntegration(),
mpvClient: deps.getMpvClient(),
showMpvOsd: deps.showMpvOsd,
});
if (created) {
deps.recordCardsMined(1);
}
};
}

View File

@@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCliCommandContext } from './cli-command-context';
function createDeps() {
let socketPath = '/tmp/mpv.sock';
const logs: string[] = [];
const browserErrors: string[] = [];
return {
deps: {
getSocketPath: () => socketPath,
setSocketPath: (value: string) => {
socketPath = value;
},
getMpvClient: () => null,
showOsd: () => {},
texthookerService: {} as never,
getTexthookerPort: () => 6677,
setTexthookerPort: () => {},
shouldOpenBrowser: () => true,
openExternal: async () => {},
logBrowserOpenError: (url: string) => browserErrors.push(url),
isOverlayInitialized: () => true,
initializeOverlay: () => {},
toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
setVisibleOverlay: () => {},
setInvisibleOverlay: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWordCache: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
getAnilistStatus: () => ({} as never),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
openJellyfinSetup: () => {},
getAnilistQueueStatus: () => ({} as never),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
stopApp: () => {},
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 1000,
schedule: (fn: () => void) => setTimeout(fn, 0),
logInfo: (message: string) => {
logs.push(`i:${message}`);
},
logWarn: (message: string) => {
logs.push(`w:${message}`);
},
logError: (message: string) => {
logs.push(`e:${message}`);
},
},
getLogs: () => logs,
getBrowserErrors: () => browserErrors,
};
}
test('cli command context proxies socket path getters/setters', () => {
const { deps } = createDeps();
const context = createCliCommandContext(deps);
assert.equal(context.getSocketPath(), '/tmp/mpv.sock');
context.setSocketPath('/tmp/next.sock');
assert.equal(context.getSocketPath(), '/tmp/next.sock');
});
test('cli command context openInBrowser reports failures', async () => {
const { deps, getBrowserErrors } = createDeps();
deps.openExternal = async () => {
throw new Error('no browser');
};
const context = createCliCommandContext(deps);
context.openInBrowser('https://example.com');
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(getBrowserErrors(), ['https://example.com']);
});
test('cli command context log methods map to deps loggers', () => {
const { deps, getLogs } = createDeps();
const context = createCliCommandContext(deps);
context.log('info');
context.warn('warn');
context.error('error', new Error('x'));
assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']);
});

View File

@@ -0,0 +1,106 @@
import type { CliArgs } from '../../cli/args';
import type {
CliCommandRuntimeServiceContext,
CliCommandRuntimeServiceContextHandlers,
} from '../cli-runtime';
type MpvClientLike = CliCommandRuntimeServiceContext['getClient'] extends () => infer T ? T : never;
export type CliCommandContextFactoryDeps = {
getSocketPath: () => string;
setSocketPath: (socketPath: string) => void;
getMpvClient: () => MpvClientLike;
showOsd: (text: string) => void;
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
openJellyfinSetup: CliCommandRuntimeServiceContext['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
};
export function createCliCommandContext(
deps: CliCommandContextFactoryDeps,
): CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers {
return {
getSocketPath: deps.getSocketPath,
setSocketPath: deps.setSocketPath,
getClient: deps.getMpvClient,
showOsd: deps.showOsd,
texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort,
shouldOpenBrowser: deps.shouldOpenBrowser,
openInBrowser: (url: string) => {
void deps.openExternal(url).catch((error) => {
deps.logBrowserOpenError(url, error);
});
},
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
setVisibleOverlay: deps.setVisibleOverlay,
setInvisibleOverlay: deps.setInvisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard,
startPendingMineSentenceMultiple: deps.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: deps.updateLastCardFromClipboard,
refreshKnownWordCache: deps.refreshKnownWordCache,
triggerFieldGrouping: deps.triggerFieldGrouping,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
getAnilistStatus: deps.getAnilistStatus,
clearAnilistToken: deps.clearAnilistToken,
openAnilistSetup: deps.openAnilistSetup,
openJellyfinSetup: deps.openJellyfinSetup,
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
runJellyfinCommand: deps.runJellyfinCommand,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
stopApp: deps.stopApp,
hasMainWindow: deps.hasMainWindow,
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule,
log: deps.logInfo,
warn: deps.logWarn,
error: deps.logError,
};
}

View File

@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGetConfiguredShortcutsHandler,
createRefreshGlobalAndOverlayShortcutsHandler,
createRegisterGlobalShortcutsHandler,
} from './global-shortcuts';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
function createShortcuts(): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I',
copySubtitle: 's',
copySubtitleMultiple: 'CommandOrControl+s',
updateLastCardFromClipboard: 'c',
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: 'q',
mineSentenceMultiple: 'w',
multiCopyTimeoutMs: 5000,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
};
}
test('configured shortcuts handler resolves from current config', () => {
const calls: string[] = [];
const config = {} as never;
const defaultConfig = {} as never;
const shortcuts = createShortcuts();
const getConfiguredShortcuts = createGetConfiguredShortcutsHandler({
getResolvedConfig: () => config,
defaultConfig,
resolveConfiguredShortcuts: (nextConfig, nextDefaultConfig) => {
calls.push('resolve');
assert.equal(nextConfig, config);
assert.equal(nextDefaultConfig, defaultConfig);
return shortcuts;
},
});
assert.equal(getConfiguredShortcuts(), shortcuts);
assert.deepEqual(calls, ['resolve']);
});
test('register global shortcuts handler passes through callbacks and shortcuts', () => {
const calls: string[] = [];
const shortcuts = createShortcuts();
const mainWindow = {} as never;
const registerGlobalShortcuts = createRegisterGlobalShortcutsHandler({
getConfiguredShortcuts: () => shortcuts,
registerGlobalShortcutsCore: (options) => {
calls.push('register');
assert.equal(options.shortcuts, shortcuts);
assert.equal(options.isDev, true);
assert.equal(options.getMainWindow(), mainWindow);
options.onToggleVisibleOverlay();
options.onToggleInvisibleOverlay();
options.onOpenYomitanSettings();
},
onToggleVisibleOverlay: () => calls.push('toggle-visible'),
onToggleInvisibleOverlay: () => calls.push('toggle-invisible'),
onOpenYomitanSettings: () => calls.push('open-yomitan'),
isDev: true,
getMainWindow: () => mainWindow,
});
registerGlobalShortcuts();
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']);
});
test('refresh global and overlay shortcuts unregisters then re-registers', () => {
const calls: string[] = [];
const refresh = createRefreshGlobalAndOverlayShortcutsHandler({
unregisterAllGlobalShortcuts: () => calls.push('unregister'),
registerGlobalShortcuts: () => calls.push('register'),
syncOverlayShortcuts: () => calls.push('sync-overlay'),
});
refresh();
assert.deepEqual(calls, ['unregister', 'register', 'sync-overlay']);
});

View File

@@ -0,0 +1,48 @@
import type { Config } from '../../types';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
import type { RegisterGlobalShortcutsServiceOptions } from '../../core/services/shortcut';
export function createGetConfiguredShortcutsHandler(deps: {
getResolvedConfig: () => Config;
defaultConfig: Config;
resolveConfiguredShortcuts: (
config: Config,
defaultConfig: Config,
) => ConfiguredShortcuts;
}) {
return (): ConfiguredShortcuts =>
deps.resolveConfiguredShortcuts(deps.getResolvedConfig(), deps.defaultConfig);
}
export function createRegisterGlobalShortcutsHandler(deps: {
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
onToggleVisibleOverlay: () => void;
onToggleInvisibleOverlay: () => void;
onOpenYomitanSettings: () => void;
isDev: boolean;
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
}) {
return (): void => {
deps.registerGlobalShortcutsCore({
shortcuts: deps.getConfiguredShortcuts(),
onToggleVisibleOverlay: deps.onToggleVisibleOverlay,
onToggleInvisibleOverlay: deps.onToggleInvisibleOverlay,
onOpenYomitanSettings: deps.onOpenYomitanSettings,
isDev: deps.isDev,
getMainWindow: deps.getMainWindow,
});
};
}
export function createRefreshGlobalAndOverlayShortcutsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
registerGlobalShortcuts: () => void;
syncOverlayShortcuts: () => void;
}) {
return (): void => {
deps.unregisterAllGlobalShortcuts();
deps.registerGlobalShortcuts();
deps.syncOverlayShortcuts();
};
}

View File

@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCopyCurrentSubtitleHandler,
createHandleMineSentenceDigitHandler,
createHandleMultiCopyDigitHandler,
} from './mining-actions';
test('multi-copy digit handler forwards tracker/clipboard/osd deps', () => {
const calls: string[] = [];
const tracker = {};
const handleMultiCopyDigit = createHandleMultiCopyDigitHandler({
getSubtitleTimingTracker: () => tracker,
writeClipboardText: (text) => calls.push(`clipboard:${text}`),
showMpvOsd: (text) => calls.push(`osd:${text}`),
handleMultiCopyDigitCore: (count, options) => {
assert.equal(count, 3);
assert.equal(options.subtitleTimingTracker, tracker);
options.writeClipboardText('copied');
options.showMpvOsd('done');
},
});
handleMultiCopyDigit(3);
assert.deepEqual(calls, ['clipboard:copied', 'osd:done']);
});
test('copy current subtitle handler forwards tracker/clipboard/osd deps', () => {
const calls: string[] = [];
const tracker = {};
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
getSubtitleTimingTracker: () => tracker,
writeClipboardText: (text) => calls.push(`clipboard:${text}`),
showMpvOsd: (text) => calls.push(`osd:${text}`),
copyCurrentSubtitleCore: (options) => {
assert.equal(options.subtitleTimingTracker, tracker);
options.writeClipboardText('subtitle');
options.showMpvOsd('copied');
},
});
copyCurrentSubtitle();
assert.deepEqual(calls, ['clipboard:subtitle', 'osd:copied']);
});
test('mine sentence digit handler forwards all dependencies', () => {
const calls: string[] = [];
const tracker = {};
const integration = {};
const handleMineSentenceDigit = createHandleMineSentenceDigitHandler({
getSubtitleTimingTracker: () => tracker,
getAnkiIntegration: () => integration,
getCurrentSecondarySubText: () => 'secondary',
showMpvOsd: (text) => calls.push(`osd:${text}`),
logError: (message) => calls.push(`err:${message}`),
onCardsMined: (count) => calls.push(`cards:${count}`),
handleMineSentenceDigitCore: (count, options) => {
assert.equal(count, 4);
assert.equal(options.subtitleTimingTracker, tracker);
assert.equal(options.ankiIntegration, integration);
assert.equal(options.getCurrentSecondarySubText(), 'secondary');
options.showMpvOsd('mine');
options.logError('boom', new Error('x'));
options.onCardsMined(2);
},
});
handleMineSentenceDigit(4);
assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']);
});

View File

@@ -0,0 +1,73 @@
export function createHandleMultiCopyDigitHandler<TSubtitleTimingTracker>(deps: {
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
handleMultiCopyDigitCore: (
count: number,
options: {
subtitleTimingTracker: TSubtitleTimingTracker;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
},
) => void;
}) {
return (count: number): void => {
deps.handleMultiCopyDigitCore(count, {
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
writeClipboardText: deps.writeClipboardText,
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createCopyCurrentSubtitleHandler<TSubtitleTimingTracker>(deps: {
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
copyCurrentSubtitleCore: (options: {
subtitleTimingTracker: TSubtitleTimingTracker;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
}) => void;
}) {
return (): void => {
deps.copyCurrentSubtitleCore({
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
writeClipboardText: deps.writeClipboardText,
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createHandleMineSentenceDigitHandler<TSubtitleTimingTracker, TAnkiIntegration>(
deps: {
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
getAnkiIntegration: () => TAnkiIntegration;
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
handleMineSentenceDigitCore: (
count: number,
options: {
subtitleTimingTracker: TSubtitleTimingTracker;
ankiIntegration: TAnkiIntegration;
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
},
) => void;
},
) {
return (count: number): void => {
deps.handleMineSentenceDigitCore(count, {
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
ankiIntegration: deps.getAnkiIntegration(),
getCurrentSecondarySubText: deps.getCurrentSecondarySubText,
showMpvOsd: deps.showMpvOsd,
logError: deps.logError,
onCardsMined: deps.onCardsMined,
});
};
}

View File

@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createBindMpvClientEventHandlers,
createHandleMpvConnectionChangeHandler,
createHandleMpvSubtitleTimingHandler,
} from './mpv-client-event-bindings';
test('mpv connection handler reports stop and quits when disconnect guard passes', () => {
const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: (callback) => {
calls.push('schedule');
callback();
},
isMpvConnected: () => false,
quitApp: () => calls.push('quit'),
});
handler({ connected: false });
assert.deepEqual(calls, ['report-stop', 'schedule', 'quit']);
});
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: () => calls.push('immersion'),
hasSubtitleTimingTracker: () => true,
recordSubtitleTiming: () => calls.push('timing'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('post-watch');
},
logError: () => calls.push('error'),
});
handler({ text: ' ', start: 1, end: 2 });
assert.deepEqual(calls, []);
});
test('mpv event bindings register all expected events', () => {
const seenEvents: string[] = [];
const bindHandlers = createBindMpvClientEventHandlers({
onConnectionChange: () => {},
onSubtitleChange: () => {},
onSubtitleAssChange: () => {},
onSecondarySubtitleChange: () => {},
onSubtitleTiming: () => {},
onMediaPathChange: () => {},
onMediaTitleChange: () => {},
onTimePosChange: () => {},
onPauseChange: () => {},
onSubtitleMetricsChange: () => {},
onSecondarySubtitleVisibility: () => {},
});
bindHandlers({
on: (event) => {
seenEvents.push(event);
},
});
assert.deepEqual(seenEvents, [
'connection-change',
'subtitle-change',
'subtitle-ass-change',
'secondary-subtitle-change',
'subtitle-timing',
'media-path-change',
'media-title-change',
'time-pos-change',
'pause-change',
'subtitle-metrics-change',
'secondary-subtitle-visibility',
]);
});

View File

@@ -0,0 +1,84 @@
type MpvBindingEventName =
| 'connection-change'
| 'subtitle-change'
| 'subtitle-ass-change'
| 'secondary-subtitle-change'
| 'subtitle-timing'
| 'media-path-change'
| 'media-title-change'
| 'time-pos-change'
| 'pause-change'
| 'subtitle-metrics-change'
| 'secondary-subtitle-visibility';
type MpvEventClient = {
on: <K extends MpvBindingEventName>(event: K, handler: (payload: any) => void) => void;
};
export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
scheduleQuitCheck: (callback: () => void) => void;
isMpvConnected: () => boolean;
quitApp: () => void;
}) {
return ({ connected }: { connected: boolean }): void => {
if (connected) return;
deps.reportJellyfinRemoteStopped();
if (!deps.hasInitialJellyfinPlayArg()) return;
if (deps.isOverlayRuntimeInitialized()) return;
if (!deps.isQuitOnDisconnectArmed()) return;
deps.scheduleQuitCheck(() => {
if (deps.isMpvConnected()) return;
deps.quitApp();
});
};
}
export function createHandleMpvSubtitleTimingHandler(deps: {
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
hasSubtitleTimingTracker: () => boolean;
recordSubtitleTiming: (text: string, start: number, end: number) => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logError: (message: string, error: unknown) => void;
}) {
return ({ text, start, end }: { text: string; start: number; end: number }): void => {
if (!text.trim()) return;
deps.recordImmersionSubtitleLine(text, start, end);
if (!deps.hasSubtitleTimingTracker()) return;
deps.recordSubtitleTiming(text, start, end);
void deps.maybeRunAnilistPostWatchUpdate().catch((error) => {
deps.logError('AniList post-watch update failed unexpectedly', error);
});
};
}
export function createBindMpvClientEventHandlers(deps: {
onConnectionChange: (payload: { connected: boolean }) => void;
onSubtitleChange: (payload: { text: string }) => void;
onSubtitleAssChange: (payload: { text: string }) => void;
onSecondarySubtitleChange: (payload: { text: string }) => void;
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
onMediaPathChange: (payload: { path: string }) => void;
onMediaTitleChange: (payload: { title: string }) => void;
onTimePosChange: (payload: { time: number }) => void;
onPauseChange: (payload: { paused: boolean }) => void;
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;
onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
}) {
return (mpvClient: MpvEventClient): void => {
mpvClient.on('connection-change', deps.onConnectionChange);
mpvClient.on('subtitle-change', deps.onSubtitleChange);
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
mpvClient.on('media-path-change', deps.onMediaPathChange);
mpvClient.on('media-title-change', deps.onMediaTitleChange);
mpvClient.on('time-pos-change', deps.onTimePosChange);
mpvClient.on('pause-change', deps.onPauseChange);
mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange);
mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility);
};
}

View File

@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMpvClientRuntimeServiceFactory } from './mpv-client-runtime-service';
test('mpv runtime service factory constructs client, binds handlers, and connects', () => {
const calls: string[] = [];
let constructedSocketPath = '';
class FakeClient {
connect(): void {
calls.push('connect');
}
constructor(socketPath: string) {
constructedSocketPath = socketPath;
calls.push('construct');
}
}
const createRuntimeService = createMpvClientRuntimeServiceFactory({
createClient: FakeClient,
socketPath: '/tmp/mpv.sock',
options: {
getResolvedConfig: () => ({}),
autoStartOverlay: true,
setOverlayVisible: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
bindEventHandlers: () => {
calls.push('bind');
},
});
const client = createRuntimeService();
assert.ok(client instanceof FakeClient);
assert.equal(constructedSocketPath, '/tmp/mpv.sock');
assert.deepEqual(calls, ['construct', 'bind', 'connect']);
});

View File

@@ -0,0 +1,35 @@
type MpvClientCtorBaseOptions = {
getResolvedConfig: () => unknown;
autoStartOverlay: boolean;
setOverlayVisible: (visible: boolean) => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
};
type MpvClientLike = {
connect: () => void;
};
type MpvClientCtor<TClient extends MpvClientLike, TOptions extends MpvClientCtorBaseOptions> = new (
socketPath: string,
options: TOptions,
) => TClient;
export function createMpvClientRuntimeServiceFactory<
TClient extends MpvClientLike,
TOptions extends MpvClientCtorBaseOptions,
>(deps: {
createClient: MpvClientCtor<TClient, TOptions>;
socketPath: string;
options: TOptions;
bindEventHandlers: (client: TClient) => void;
}) {
return (): TClient => {
const mpvClient = new deps.createClient(deps.socketPath, deps.options);
deps.bindEventHandlers(mpvClient);
mpvClient.connect();
return mpvClient;
};
}

View File

@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-log';
test('append mpv log writes timestamped message', () => {
const calls: string[] = [];
const appendToMpvLog = createAppendToMpvLogHandler({
logPath: '/tmp/subminer/mpv.log',
dirname: (targetPath) => {
calls.push(`dirname:${targetPath}`);
return '/tmp/subminer';
},
mkdirSync: (targetPath) => {
calls.push(`mkdir:${targetPath}`);
},
appendFileSync: (_targetPath, data) => {
calls.push(`append:${data.trimEnd()}`);
},
now: () => new Date('2026-02-20T00:00:00.000Z'),
});
appendToMpvLog('hello');
assert.deepEqual(calls, [
'dirname:/tmp/subminer/mpv.log',
'mkdir:/tmp/subminer',
'append:[2026-02-20T00:00:00.000Z] hello',
]);
});
test('append mpv log swallows filesystem errors', () => {
const appendToMpvLog = createAppendToMpvLogHandler({
logPath: '/tmp/subminer/mpv.log',
dirname: () => '/tmp/subminer',
mkdirSync: () => {
throw new Error('disk error');
},
appendFileSync: () => {
throw new Error('should not reach');
},
now: () => new Date('2026-02-20T00:00:00.000Z'),
});
assert.doesNotThrow(() => appendToMpvLog('hello'));
});
test('show mpv osd logs marker and forwards fallback logging', () => {
const calls: string[] = [];
const client = { connected: false, send: () => {} } as never;
const showMpvOsd = createShowMpvOsdHandler({
appendToMpvLog: (message) => calls.push(`append:${message}`),
showMpvOsdRuntime: (_client, text, fallbackLog) => {
calls.push(`show:${text}`);
fallbackLog('fallback-line');
},
getMpvClient: () => client,
logInfo: (line) => calls.push(`info:${line}`),
});
showMpvOsd('subtitle copied');
assert.deepEqual(calls, [
'append:[OSD] subtitle copied',
'show:subtitle copied',
'info:fallback-line',
]);
});

View File

@@ -0,0 +1,42 @@
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
export function createAppendToMpvLogHandler(deps: {
logPath: string;
dirname: (targetPath: string) => string;
mkdirSync: (targetPath: string, options: { recursive: boolean }) => void;
appendFileSync: (
targetPath: string,
data: string,
options: { encoding: 'utf8' },
) => void;
now: () => Date;
}) {
return (message: string): void => {
try {
deps.mkdirSync(deps.dirname(deps.logPath), { recursive: true });
deps.appendFileSync(deps.logPath, `[${deps.now().toISOString()}] ${message}\n`, {
encoding: 'utf8',
});
} catch {
// best-effort logging
}
};
}
export function createShowMpvOsdHandler(deps: {
appendToMpvLog: (message: string) => void;
showMpvOsdRuntime: (
mpvClient: MpvRuntimeClientLike | null,
text: string,
fallbackLog: (line: string) => void,
) => void;
getMpvClient: () => MpvRuntimeClientLike | null;
logInfo: (line: string) => void;
}) {
return (text: string): void => {
deps.appendToMpvLog(`[OSD] ${text}`);
deps.showMpvOsdRuntime(deps.getMpvClient(), text, (line) => {
deps.logInfo(line);
});
};
}

View File

@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createUpdateMpvSubtitleRenderMetricsHandler } from './mpv-subtitle-render-metrics';
import type { MpvSubtitleRenderMetrics } from '../../types';
const BASE_METRICS: MpvSubtitleRenderMetrics = {
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
};
test('subtitle render metrics handler no-ops when patch does not change state', () => {
let metrics = { ...BASE_METRICS };
let broadcasts = 0;
const updateMetrics = createUpdateMpvSubtitleRenderMetricsHandler({
getCurrentMetrics: () => metrics,
setCurrentMetrics: (next) => {
metrics = next;
},
applyPatch: (current) => ({ next: current, changed: false }),
broadcastMetrics: () => {
broadcasts += 1;
},
});
updateMetrics({});
assert.equal(broadcasts, 0);
});
test('subtitle render metrics handler updates and broadcasts when changed', () => {
let metrics = { ...BASE_METRICS };
let broadcasts = 0;
const updateMetrics = createUpdateMpvSubtitleRenderMetricsHandler({
getCurrentMetrics: () => metrics,
setCurrentMetrics: (next) => {
metrics = next;
},
applyPatch: (current, patch) => ({
next: { ...current, ...patch },
changed: true,
}),
broadcastMetrics: () => {
broadcasts += 1;
},
});
updateMetrics({ subPos: 80 });
assert.equal(metrics.subPos, 80);
assert.equal(broadcasts, 1);
});

View File

@@ -0,0 +1,18 @@
import type { MpvSubtitleRenderMetrics } from '../../types';
export function createUpdateMpvSubtitleRenderMetricsHandler(deps: {
getCurrentMetrics: () => MpvSubtitleRenderMetrics;
setCurrentMetrics: (metrics: MpvSubtitleRenderMetrics) => void;
applyPatch: (
current: MpvSubtitleRenderMetrics,
patch: Partial<MpvSubtitleRenderMetrics>,
) => { next: MpvSubtitleRenderMetrics; changed: boolean };
broadcastMetrics: (metrics: MpvSubtitleRenderMetrics) => void;
}) {
return (patch: Partial<MpvSubtitleRenderMetrics>): void => {
const { next, changed } = deps.applyPatch(deps.getCurrentMetrics(), patch);
if (!changed) return;
deps.setCurrentMetrics(next);
deps.broadcastMetrics(next);
};
}

View File

@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCancelNumericShortcutSessionHandler,
createStartNumericShortcutSessionHandler,
} from './numeric-shortcut-session-handlers';
test('cancel numeric shortcut session handler cancels active session', () => {
const calls: string[] = [];
const cancel = createCancelNumericShortcutSessionHandler({
session: {
start: () => {},
cancel: () => calls.push('cancel'),
},
});
cancel();
assert.deepEqual(calls, ['cancel']);
});
test('start numeric shortcut session handler forwards timeout, messages, and onDigit', () => {
const calls: string[] = [];
const start = createStartNumericShortcutSessionHandler({
session: {
cancel: () => {},
start: ({ timeoutMs, onDigit, messages }) => {
calls.push(`timeout:${timeoutMs}`);
calls.push(`prompt:${messages.prompt}`);
onDigit(3);
},
},
onDigit: (digit) => calls.push(`digit:${digit}`),
messages: {
prompt: 'Prompt',
timeout: 'Timeout',
cancelled: 'Cancelled',
},
});
start(1200);
assert.deepEqual(calls, ['timeout:1200', 'prompt:Prompt', 'digit:3']);
});

View File

@@ -0,0 +1,31 @@
import type {
NumericShortcutSessionMessages,
NumericShortcutSessionStartParams,
} from '../../core/services/numeric-shortcut';
type NumericShortcutSessionLike = {
start: (params: NumericShortcutSessionStartParams) => void;
cancel: () => void;
};
export function createCancelNumericShortcutSessionHandler(deps: {
session: NumericShortcutSessionLike;
}) {
return (): void => {
deps.session.cancel();
};
}
export function createStartNumericShortcutSessionHandler(deps: {
session: NumericShortcutSessionLike;
onDigit: (digit: number) => void;
messages: NumericShortcutSessionMessages;
}) {
return (timeoutMs: number): void => {
deps.session.start({
timeoutMs,
onDigit: deps.onDigit,
messages: deps.messages,
});
};
}

View File

@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInitializeOverlayRuntimeHandler } from './overlay-runtime-bootstrap';
test('overlay runtime bootstrap no-ops when already initialized', () => {
let coreCalls = 0;
const initialize = createInitializeOverlayRuntimeHandler({
isOverlayRuntimeInitialized: () => true,
initializeOverlayRuntimeCore: () => {
coreCalls += 1;
return { invisibleOverlayVisible: false };
},
buildOptions: () => ({} as never),
setInvisibleOverlayVisible: () => {},
setOverlayRuntimeInitialized: () => {},
startBackgroundWarmups: () => {},
});
initialize();
assert.equal(coreCalls, 0);
});
test('overlay runtime bootstrap runs core init and applies post-init state', () => {
const calls: string[] = [];
let initialized = false;
const initialize = createInitializeOverlayRuntimeHandler({
isOverlayRuntimeInitialized: () => initialized,
initializeOverlayRuntimeCore: () => {
calls.push('core');
return { invisibleOverlayVisible: true };
},
buildOptions: () => {
calls.push('options');
return {} as never;
},
setInvisibleOverlayVisible: (visible) => {
calls.push(`invisible:${visible ? 'yes' : 'no'}`);
},
setOverlayRuntimeInitialized: (value) => {
initialized = value;
calls.push(`initialized:${value ? 'yes' : 'no'}`);
},
startBackgroundWarmups: () => {
calls.push('warmups');
},
});
initialize();
assert.equal(initialized, true);
assert.deepEqual(calls, ['options', 'core', 'invisible:yes', 'initialized:yes', 'warmups']);
});

View File

@@ -0,0 +1,55 @@
import type { BrowserWindow } from 'electron';
import type { BaseWindowTracker } from '../../window-trackers';
import type {
AnkiConnectConfig,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
WindowGeometry,
} from '../../types';
type InitializeOverlayRuntimeCore = (options: {
backendOverride: string | null;
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
getMpvSocketPath: () => string;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
getRuntimeOptionsManager: () => {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
}) => { invisibleOverlayVisible: boolean };
export function createInitializeOverlayRuntimeHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: InitializeOverlayRuntimeCore;
buildOptions: () => Parameters<InitializeOverlayRuntimeCore>[0];
setInvisibleOverlayVisible: (visible: boolean) => void;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void;
}) {
return (): void => {
if (deps.isOverlayRuntimeInitialized()) return;
const result = deps.initializeOverlayRuntimeCore(deps.buildOptions());
deps.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
deps.setOverlayRuntimeInitialized(true);
deps.startBackgroundWarmups();
};
}

View File

@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createRefreshOverlayShortcutsHandler,
createRegisterOverlayShortcutsHandler,
createSyncOverlayShortcutsHandler,
createUnregisterOverlayShortcutsHandler,
} from './overlay-shortcuts-lifecycle';
function createRuntime(calls: string[]) {
return {
registerOverlayShortcuts: () => calls.push('register'),
unregisterOverlayShortcuts: () => calls.push('unregister'),
syncOverlayShortcuts: () => calls.push('sync'),
refreshOverlayShortcuts: () => calls.push('refresh'),
};
}
test('register overlay shortcuts handler delegates to runtime', () => {
const calls: string[] = [];
createRegisterOverlayShortcutsHandler({
overlayShortcutsRuntime: createRuntime(calls),
})();
assert.deepEqual(calls, ['register']);
});
test('unregister overlay shortcuts handler delegates to runtime', () => {
const calls: string[] = [];
createUnregisterOverlayShortcutsHandler({
overlayShortcutsRuntime: createRuntime(calls),
})();
assert.deepEqual(calls, ['unregister']);
});
test('sync overlay shortcuts handler delegates to runtime', () => {
const calls: string[] = [];
createSyncOverlayShortcutsHandler({
overlayShortcutsRuntime: createRuntime(calls),
})();
assert.deepEqual(calls, ['sync']);
});
test('refresh overlay shortcuts handler delegates to runtime', () => {
const calls: string[] = [];
createRefreshOverlayShortcutsHandler({
overlayShortcutsRuntime: createRuntime(calls),
})();
assert.deepEqual(calls, ['refresh']);
});

View File

@@ -0,0 +1,38 @@
type OverlayShortcutsRuntimeLike = {
registerOverlayShortcuts: () => void;
unregisterOverlayShortcuts: () => void;
syncOverlayShortcuts: () => void;
refreshOverlayShortcuts: () => void;
};
export function createRegisterOverlayShortcutsHandler(deps: {
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
}) {
return (): void => {
deps.overlayShortcutsRuntime.registerOverlayShortcuts();
};
}
export function createUnregisterOverlayShortcutsHandler(deps: {
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
}) {
return (): void => {
deps.overlayShortcutsRuntime.unregisterOverlayShortcuts();
};
}
export function createSyncOverlayShortcutsHandler(deps: {
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
}) {
return (): void => {
deps.overlayShortcutsRuntime.syncOverlayShortcuts();
};
}
export function createRefreshOverlayShortcutsHandler(deps: {
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
}) {
return (): void => {
deps.overlayShortcutsRuntime.refreshOverlayShortcuts();
};
}

View File

@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler,
createUpdateInvisibleOverlayBoundsHandler,
createUpdateVisibleOverlayBoundsHandler,
} from './overlay-window-layout';
test('visible bounds handler writes visible layer geometry', () => {
const calls: string[] = [];
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
setOverlayWindowBounds: (layer) => calls.push(layer),
});
handleVisible({ x: 0, y: 0, width: 100, height: 50 });
assert.deepEqual(calls, ['visible']);
});
test('invisible bounds handler writes invisible layer geometry', () => {
const calls: string[] = [];
const handleInvisible = createUpdateInvisibleOverlayBoundsHandler({
setOverlayWindowBounds: (layer) => calls.push(layer),
});
handleInvisible({ x: 0, y: 0, width: 100, height: 50 });
assert.deepEqual(calls, ['invisible']);
});
test('ensure overlay window level handler delegates to core', () => {
const calls: string[] = [];
const ensureLevel = createEnsureOverlayWindowLevelHandler({
ensureOverlayWindowLevelCore: () => calls.push('core'),
});
ensureLevel({});
assert.deepEqual(calls, ['core']);
});
test('enforce overlay layer order handler forwards resolved state', () => {
const calls: string[] = [];
const enforce = createEnforceOverlayLayerOrderHandler({
enforceOverlayLayerOrderCore: (params) => {
calls.push(params.visibleOverlayVisible ? 'visible-on' : 'visible-off');
calls.push(params.invisibleOverlayVisible ? 'invisible-on' : 'invisible-off');
params.ensureOverlayWindowLevel({});
},
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
getMainWindow: () => ({}),
getInvisibleWindow: () => ({}),
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
});
enforce();
assert.deepEqual(calls, ['visible-on', 'invisible-off', 'ensure-level']);
});

View File

@@ -0,0 +1,50 @@
import type { WindowGeometry } from '../../types';
export function createUpdateVisibleOverlayBoundsHandler(deps: {
setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void;
}) {
return (geometry: WindowGeometry): void => {
deps.setOverlayWindowBounds('visible', geometry);
};
}
export function createUpdateInvisibleOverlayBoundsHandler(deps: {
setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void;
}) {
return (geometry: WindowGeometry): void => {
deps.setOverlayWindowBounds('invisible', geometry);
};
}
export function createEnsureOverlayWindowLevelHandler(deps: {
ensureOverlayWindowLevelCore: (window: unknown) => void;
}) {
return (window: unknown): void => {
deps.ensureOverlayWindowLevelCore(window);
};
}
export function createEnforceOverlayLayerOrderHandler(deps: {
enforceOverlayLayerOrderCore: (params: {
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
mainWindow: unknown;
invisibleWindow: unknown;
ensureOverlayWindowLevel: (window: unknown) => void;
}) => void;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
getMainWindow: () => unknown;
getInvisibleWindow: () => unknown;
ensureOverlayWindowLevel: (window: unknown) => void;
}) {
return (): void => {
deps.enforceOverlayLayerOrderCore({
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
mainWindow: deps.getMainWindow(),
invisibleWindow: deps.getInvisibleWindow(),
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
});
};
}

View File

@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createLaunchBackgroundWarmupTaskHandler,
createStartBackgroundWarmupsHandler,
} from './startup-warmups';
test('launchBackgroundWarmupTask logs completion timing', async () => {
const debugLogs: string[] = [];
const launchTask = createLaunchBackgroundWarmupTaskHandler({
now: (() => {
let tick = 0;
return () => ++tick * 10;
})(),
logDebug: (message) => debugLogs.push(message),
logWarn: () => {},
});
launchTask('demo', async () => {});
await Promise.resolve();
assert.ok(debugLogs.some((line) => line.includes('[startup-warmup] demo completed in')));
});
test('startBackgroundWarmups no-ops when already started', () => {
let launches = 0;
const startWarmups = createStartBackgroundWarmupsHandler({
getStarted: () => true,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
launchTask: () => {
launches += 1;
},
createMecabTokenizerAndCheck: async () => {},
ensureYomitanExtensionLoaded: async () => {},
prewarmSubtitleDictionaries: async () => {},
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
});
startWarmups();
assert.equal(launches, 0);
});
test('startBackgroundWarmups schedules base warmups and optional jellyfin warmup', () => {
const labels: string[] = [];
let started = false;
const startWarmups = createStartBackgroundWarmupsHandler({
getStarted: () => started,
setStarted: (value) => {
started = value;
},
isTexthookerOnlyMode: () => false,
launchTask: (label) => {
labels.push(label);
},
createMecabTokenizerAndCheck: async () => {},
ensureYomitanExtensionLoaded: async () => {},
prewarmSubtitleDictionaries: async () => {},
shouldAutoConnectJellyfinRemote: () => true,
startJellyfinRemoteSession: async () => {},
});
startWarmups();
assert.equal(started, true);
assert.deepEqual(labels, [
'mecab',
'yomitan-extension',
'subtitle-dictionaries',
'jellyfin-remote-session',
]);
});

View File

@@ -0,0 +1,50 @@
export function createLaunchBackgroundWarmupTaskHandler(deps: {
now: () => number;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
}) {
return (label: string, task: () => Promise<void>): void => {
const startedAtMs = deps.now();
void task()
.then(() => {
deps.logDebug(`[startup-warmup] ${label} completed in ${deps.now() - startedAtMs}ms`);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
deps.logWarn(`[startup-warmup] ${label} failed: ${message}`);
});
};
}
export function createStartBackgroundWarmupsHandler(deps: {
getStarted: () => boolean;
setStarted: (started: boolean) => void;
isTexthookerOnlyMode: () => boolean;
launchTask: (label: string, task: () => Promise<void>) => void;
createMecabTokenizerAndCheck: () => Promise<void>;
ensureYomitanExtensionLoaded: () => Promise<void>;
prewarmSubtitleDictionaries: () => Promise<void>;
shouldAutoConnectJellyfinRemote: () => boolean;
startJellyfinRemoteSession: () => Promise<void>;
}) {
return (): void => {
if (deps.getStarted()) return;
if (deps.isTexthookerOnlyMode()) return;
deps.setStarted(true);
deps.launchTask('mecab', async () => {
await deps.createMecabTokenizerAndCheck();
});
deps.launchTask('yomitan-extension', async () => {
await deps.ensureYomitanExtensionLoaded();
});
deps.launchTask('subtitle-dictionaries', async () => {
await deps.prewarmSubtitleDictionaries();
});
if (deps.shouldAutoConnectJellyfinRemote()) {
deps.launchTask('jellyfin-remote-session', async () => {
await deps.startJellyfinRemoteSession();
});
}
};
}

View File

@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle';
test('ensure tray updates menu when tray already exists', () => {
const calls: string[] = [];
const tray = {
setContextMenu: () => calls.push('set-menu'),
setToolTip: () => calls.push('set-tooltip'),
on: () => calls.push('bind-click'),
destroy: () => calls.push('destroy'),
};
const ensureTray = createEnsureTrayHandler({
getTray: () => tray,
setTray: () => calls.push('set-tray'),
buildTrayMenu: () => ({}),
resolveTrayIconPath: () => null,
createImageFromPath: () =>
({
isEmpty: () => false,
resize: () => {
throw new Error('should not resize');
},
setTemplateImage: () => {},
}) as never,
createEmptyImage: () =>
({
isEmpty: () => true,
resize: () => {
throw new Error('should not resize');
},
setTemplateImage: () => {},
}) as never,
createTray: () => tray as never,
trayTooltip: 'SubMiner',
platform: 'darwin',
logWarn: () => calls.push('warn'),
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
});
ensureTray();
assert.deepEqual(calls, ['set-menu']);
});
test('ensure tray creates new tray and binds click handler', () => {
const calls: string[] = [];
let trayRef: unknown = null;
const ensureTray = createEnsureTrayHandler({
getTray: () => null,
setTray: (tray) => {
trayRef = tray;
calls.push('set-tray');
},
buildTrayMenu: () => ({ id: 'menu' }),
resolveTrayIconPath: () => '/tmp/icon.png',
createImageFromPath: () =>
({
isEmpty: () => false,
resize: (options: { width: number; height: number; quality?: 'best' | 'better' | 'good' }) => {
calls.push(`resize:${options.width}x${options.height}`);
return {
isEmpty: () => false,
resize: () => {
throw new Error('unexpected');
},
setTemplateImage: () => calls.push('template'),
};
},
setTemplateImage: () => calls.push('template'),
}) as never,
createEmptyImage: () =>
({
isEmpty: () => true,
resize: () => {
throw new Error('unexpected');
},
setTemplateImage: () => {},
}) as never,
createTray: () =>
({
setContextMenu: () => calls.push('set-menu'),
setToolTip: () => calls.push('set-tooltip'),
on: (_event: 'click', _handler: () => void) => {
calls.push('bind-click');
},
destroy: () => calls.push('destroy'),
}) as never,
trayTooltip: 'SubMiner',
platform: 'darwin',
logWarn: () => calls.push('warn'),
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
});
ensureTray();
assert.ok(trayRef);
assert.ok(calls.includes('set-tray'));
assert.ok(calls.includes('set-menu'));
assert.ok(calls.includes('bind-click'));
});
test('destroy tray handler destroys active tray and clears ref', () => {
const calls: string[] = [];
let tray: { destroy: () => void } | null = {
destroy: () => calls.push('destroy'),
};
const destroyTray = createDestroyTrayHandler({
getTray: () => tray as never,
setTray: (next) => {
tray = next as never;
calls.push('set-null');
},
});
destroyTray();
assert.deepEqual(calls, ['destroy', 'set-null']);
assert.equal(tray, null);
});

View File

@@ -0,0 +1,67 @@
type TrayIconLike = {
isEmpty: () => boolean;
resize: (options: { width: number; height: number; quality?: 'best' | 'better' | 'good' }) => TrayIconLike;
setTemplateImage: (enabled: boolean) => void;
};
type TrayLike = {
setContextMenu: (menu: any) => void;
setToolTip: (tooltip: string) => void;
on: (event: 'click', handler: () => void) => void;
destroy: () => void;
};
export function createEnsureTrayHandler(deps: {
getTray: () => TrayLike | null;
setTray: (tray: TrayLike | null) => void;
buildTrayMenu: () => any;
resolveTrayIconPath: () => string | null;
createImageFromPath: (iconPath: string) => TrayIconLike;
createEmptyImage: () => TrayIconLike;
createTray: (icon: TrayIconLike) => TrayLike;
trayTooltip: string;
platform: string;
logWarn: (message: string) => void;
ensureOverlayVisibleFromTrayClick: () => void;
}) {
return (): void => {
const existingTray = deps.getTray();
if (existingTray) {
existingTray.setContextMenu(deps.buildTrayMenu());
return;
}
const iconPath = deps.resolveTrayIconPath();
let trayIcon = iconPath ? deps.createImageFromPath(iconPath) : deps.createEmptyImage();
if (trayIcon.isEmpty()) {
deps.logWarn('Tray icon asset not found; using empty icon placeholder.');
}
if (deps.platform === 'darwin' && !trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 18, height: 18, quality: 'best' });
trayIcon.setTemplateImage(true);
}
if (deps.platform === 'linux' && !trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 20, height: 20 });
}
const tray = deps.createTray(trayIcon);
tray.setToolTip(deps.trayTooltip);
tray.setContextMenu(deps.buildTrayMenu());
tray.on('click', () => {
deps.ensureOverlayVisibleFromTrayClick();
});
deps.setTray(tray);
};
}
export function createDestroyTrayHandler(deps: {
getTray: () => TrayLike | null;
setTray: (tray: TrayLike | null) => void;
}) {
return (): void => {
const tray = deps.getTray();
if (!tray) return;
tray.destroy();
deps.setTray(null);
};
}

View File

@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildTrayMenuTemplateRuntime, resolveTrayIconPathRuntime } from './tray-runtime';
test('resolve tray icon picks template icon first on darwin', () => {
const path = resolveTrayIconPathRuntime({
platform: 'darwin',
resourcesPath: '/res',
appPath: '/app',
dirname: '/dist/main',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => candidate.endsWith('/res/assets/SubMinerTemplate.png'),
});
assert.equal(path, '/res/assets/SubMinerTemplate.png');
});
test('resolve tray icon returns null when no asset exists', () => {
const path = resolveTrayIconPathRuntime({
platform: 'linux',
resourcesPath: '/res',
appPath: '/app',
dirname: '/dist/main',
joinPath: (...parts) => parts.join('/'),
fileExists: () => false,
});
assert.equal(path, null);
});
test('tray menu template contains expected entries and handlers', () => {
const calls: string[] = [];
const template = buildTrayMenuTemplateRuntime({
openOverlay: () => calls.push('overlay'),
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'),
openJellyfinSetup: () => calls.push('jellyfin'),
openAnilistSetup: () => calls.push('anilist'),
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 7);
template[0].click?.();
template[5].type === 'separator' ? calls.push('separator') : calls.push('bad');
template[6].click?.();
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
});

View File

@@ -0,0 +1,73 @@
export function resolveTrayIconPathRuntime(deps: {
platform: string;
resourcesPath: string;
appPath: string;
dirname: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}): string | null {
const iconNames =
deps.platform === 'darwin'
? ['SubMinerTemplate.png', 'SubMinerTemplate@2x.png', 'SubMiner.png']
: ['SubMiner.png'];
const baseDirs = [
deps.joinPath(deps.resourcesPath, 'assets'),
deps.joinPath(deps.appPath, 'assets'),
deps.joinPath(deps.dirname, '..', 'assets'),
deps.joinPath(deps.dirname, '..', '..', 'assets'),
];
for (const baseDir of baseDirs) {
for (const iconName of iconNames) {
const candidate = deps.joinPath(baseDir, iconName);
if (deps.fileExists(candidate)) {
return candidate;
}
}
}
return null;
}
export type TrayMenuActionHandlers = {
openOverlay: () => void;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
openAnilistSetup: () => void;
quitApp: () => void;
};
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
label?: string;
type?: 'separator';
click?: () => void;
}> {
return [
{
label: 'Open Overlay',
click: handlers.openOverlay,
},
{
label: 'Open Yomitan Settings',
click: handlers.openYomitanSettings,
},
{
label: 'Open Runtime Options',
click: handlers.openRuntimeOptions,
},
{
label: 'Configure Jellyfin',
click: handlers.openJellyfinSetup,
},
{
label: 'Configure AniList',
click: handlers.openAnilistSetup,
},
{ type: 'separator' },
{
label: 'Quit',
click: handlers.quitApp,
},
];
}

View File

@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOpenYomitanSettingsHandler } from './yomitan-settings-opener';
test('yomitan opener warns when extension cannot be loaded', async () => {
const logs: string[] = [];
const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => null,
openYomitanSettingsWindow: () => {
throw new Error('should not open');
},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: (message) => logs.push(message),
logError: () => logs.push('error'),
});
openSettings();
await Promise.resolve();
await Promise.resolve();
assert.ok(logs.includes('Unable to open Yomitan settings: extension failed to load.'));
});
test('yomitan opener opens settings window when extension is available', async () => {
let opened = false;
const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
openYomitanSettingsWindow: () => {
opened = true;
},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: () => {},
logError: () => {},
});
openSettings();
await Promise.resolve();
await Promise.resolve();
assert.equal(opened, true);
});

View File

@@ -0,0 +1,32 @@
type YomitanExtensionLike = unknown;
type BrowserWindowLike = unknown;
export function createOpenYomitanSettingsHandler(deps: {
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
openYomitanSettingsWindow: (params: {
yomitanExt: YomitanExtensionLike;
getExistingWindow: () => BrowserWindowLike | null;
setWindow: (window: BrowserWindowLike | null) => void;
}) => void;
getExistingWindow: () => BrowserWindowLike | null;
setWindow: (window: BrowserWindowLike | null) => void;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
}) {
return (): void => {
void (async () => {
const extension = await deps.ensureYomitanExtensionLoaded();
if (!extension) {
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
return;
}
deps.openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: deps.getExistingWindow,
setWindow: deps.setWindow,
});
})().catch((error) => {
deps.logError('Failed to open Yomitan settings window.', error);
});
};
}