From aaa19a33c5829998c261bef677ea3f03bb588331 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 19 Feb 2026 19:08:53 -0800 Subject: [PATCH] refactor: split main runtime wrappers into focused modules --- docs/subagents/INDEX.md | 4 +- .../codex-task85-20260219T233711Z-46hc.md | 66 +- src/main.ts | 590 ++++++++++-------- src/main/runtime/anki-actions.test.ts | 89 +++ src/main/runtime/anki-actions.ts | 90 +++ src/main/runtime/cli-command-context.test.ts | 96 +++ src/main/runtime/cli-command-context.ts | 106 ++++ src/main/runtime/global-shortcuts.test.ts | 85 +++ src/main/runtime/global-shortcuts.ts | 48 ++ src/main/runtime/mining-actions.test.ts | 70 +++ src/main/runtime/mining-actions.ts | 73 +++ .../runtime/mpv-client-event-bindings.test.ts | 79 +++ src/main/runtime/mpv-client-event-bindings.ts | 84 +++ .../mpv-client-runtime-service.test.ts | 40 ++ .../runtime/mpv-client-runtime-service.ts | 35 ++ src/main/runtime/mpv-osd-log.test.ts | 65 ++ src/main/runtime/mpv-osd-log.ts | 42 ++ .../mpv-subtitle-render-metrics.test.ts | 63 ++ .../runtime/mpv-subtitle-render-metrics.ts | 18 + .../numeric-shortcut-session-handlers.test.ts | 42 ++ .../numeric-shortcut-session-handlers.ts | 31 + .../runtime/overlay-runtime-bootstrap.test.ts | 51 ++ src/main/runtime/overlay-runtime-bootstrap.ts | 55 ++ .../overlay-shortcuts-lifecycle.test.ts | 49 ++ .../runtime/overlay-shortcuts-lifecycle.ts | 38 ++ .../runtime/overlay-window-layout.test.ts | 53 ++ src/main/runtime/overlay-window-layout.ts | 50 ++ src/main/runtime/startup-warmups.test.ts | 71 +++ src/main/runtime/startup-warmups.ts | 50 ++ src/main/runtime/tray-lifecycle.test.ts | 119 ++++ src/main/runtime/tray-lifecycle.ts | 67 ++ src/main/runtime/tray-runtime.test.ts | 45 ++ src/main/runtime/tray-runtime.ts | 73 +++ .../runtime/yomitan-settings-opener.test.ts | 41 ++ src/main/runtime/yomitan-settings-opener.ts | 32 + 35 files changed, 2347 insertions(+), 263 deletions(-) create mode 100644 src/main/runtime/anki-actions.test.ts create mode 100644 src/main/runtime/anki-actions.ts create mode 100644 src/main/runtime/cli-command-context.test.ts create mode 100644 src/main/runtime/cli-command-context.ts create mode 100644 src/main/runtime/global-shortcuts.test.ts create mode 100644 src/main/runtime/global-shortcuts.ts create mode 100644 src/main/runtime/mining-actions.test.ts create mode 100644 src/main/runtime/mining-actions.ts create mode 100644 src/main/runtime/mpv-client-event-bindings.test.ts create mode 100644 src/main/runtime/mpv-client-event-bindings.ts create mode 100644 src/main/runtime/mpv-client-runtime-service.test.ts create mode 100644 src/main/runtime/mpv-client-runtime-service.ts create mode 100644 src/main/runtime/mpv-osd-log.test.ts create mode 100644 src/main/runtime/mpv-osd-log.ts create mode 100644 src/main/runtime/mpv-subtitle-render-metrics.test.ts create mode 100644 src/main/runtime/mpv-subtitle-render-metrics.ts create mode 100644 src/main/runtime/numeric-shortcut-session-handlers.test.ts create mode 100644 src/main/runtime/numeric-shortcut-session-handlers.ts create mode 100644 src/main/runtime/overlay-runtime-bootstrap.test.ts create mode 100644 src/main/runtime/overlay-runtime-bootstrap.ts create mode 100644 src/main/runtime/overlay-shortcuts-lifecycle.test.ts create mode 100644 src/main/runtime/overlay-shortcuts-lifecycle.ts create mode 100644 src/main/runtime/overlay-window-layout.test.ts create mode 100644 src/main/runtime/overlay-window-layout.ts create mode 100644 src/main/runtime/startup-warmups.test.ts create mode 100644 src/main/runtime/startup-warmups.ts create mode 100644 src/main/runtime/tray-lifecycle.test.ts create mode 100644 src/main/runtime/tray-lifecycle.ts create mode 100644 src/main/runtime/tray-runtime.test.ts create mode 100644 src/main/runtime/tray-runtime.ts create mode 100644 src/main/runtime/yomitan-settings-opener.test.ts create mode 100644 src/main/runtime/yomitan-settings-opener.ts diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index 033185c..1f7d628 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -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` | diff --git a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md index 6dfc571..e947882 100644 --- a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md +++ b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md @@ -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. diff --git a/src/main.ts b/src/main.ts index 6e18875..f167658 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); -} +const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler({ + setOverlayWindowBounds: (layer, geometry) => overlayManager.setOverlayWindowBounds(layer, geometry), +}); -function updateInvisibleOverlayBounds(geometry: WindowGeometry): void { - overlayManager.setOverlayWindowBounds('invisible', geometry); -} +const updateInvisibleOverlayBounds = createUpdateInvisibleOverlayBoundsHandler({ + setOverlayWindowBounds: (layer, geometry) => overlayManager.setOverlayWindowBounds(layer, geometry), +}); -function ensureOverlayWindowLevel(window: BrowserWindow): void { - ensureOverlayWindowLevelCore(window); -} +const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler({ + ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), +}); -function enforceOverlayLayerOrder(): void { - enforceOverlayLayerOrderCore({ - visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(), - invisibleOverlayVisible: overlayManager.getInvisibleOverlayVisible(), - mainWindow: overlayManager.getMainWindow(), - invisibleWindow: overlayManager.getInvisibleWindow(), - ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), - }); -} +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 { return loadYomitanExtensionCore({ @@ -2236,208 +2280,188 @@ 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', () => { - if (!appState.overlayRuntimeInitialized) { - initializeOverlayRuntime(); - } - setVisibleOverlayVisible(true); - }); + 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({ - backendOverride: appState.backendOverride, - getInitialInvisibleOverlayVisibility: () => - configDerivedRuntime.getInitialInvisibleOverlayVisibility(), - createMainWindow: () => { - createMainWindow(); + createInitializeOverlayRuntimeHandler({ + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options), + buildOptions: () => ({ + backendOverride: appState.backendOverride, + getInitialInvisibleOverlayVisibility: () => + configDerivedRuntime.getInitialInvisibleOverlayVisibility(), + createMainWindow: () => { + createMainWindow(); + }, + createInvisibleWindow: () => { + createInvisibleWindow(); + }, + registerGlobalShortcuts: () => { + registerGlobalShortcuts(); + }, + updateVisibleOverlayBounds: (geometry) => { + updateVisibleOverlayBounds(geometry); + }, + updateInvisibleOverlayBounds: (geometry) => { + updateInvisibleOverlayBounds(geometry); + }, + isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + isInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + updateVisibleOverlayVisibility: () => { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, + updateInvisibleOverlayVisibility: () => { + overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); + }, + getOverlayWindows: () => getOverlayWindows(), + syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), + setWindowTracker: (tracker) => { + appState.windowTracker = tracker; + }, + getResolvedConfig: () => getResolvedConfig(), + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getMpvClient: () => appState.mpvClient, + getMpvSocketPath: () => appState.mpvSocketPath, + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + setAnkiIntegration: (integration) => { + appState.ankiIntegration = integration as AnkiIntegration | null; + }, + showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback(), + getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + }), + setInvisibleOverlayVisible: (visible) => { + overlayManager.setInvisibleOverlayVisible(visible); }, - createInvisibleWindow: () => { - createInvisibleWindow(); + setOverlayRuntimeInitialized: (initialized) => { + appState.overlayRuntimeInitialized = initialized; }, - registerGlobalShortcuts: () => { - registerGlobalShortcuts(); - }, - updateVisibleOverlayBounds: (geometry) => { - updateVisibleOverlayBounds(geometry); - }, - updateInvisibleOverlayBounds: (geometry) => { - updateInvisibleOverlayBounds(geometry); - }, - isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - isInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), - updateVisibleOverlayVisibility: () => { - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - }, - updateInvisibleOverlayVisibility: () => { - overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); - }, - getOverlayWindows: () => getOverlayWindows(), - syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), - setWindowTracker: (tracker) => { - appState.windowTracker = tracker; - }, - getResolvedConfig: () => getResolvedConfig(), - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - getMpvClient: () => appState.mpvClient, - getMpvSocketPath: () => appState.mpvSocketPath, - getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - setAnkiIntegration: (integration) => { - appState.ankiIntegration = integration as AnkiIntegration | null; - }, - showDesktopNotification, - createFieldGroupingCallback: () => createFieldGroupingCallback(), - getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), - }); - overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible); - appState.overlayRuntimeInitialized = true; - startBackgroundWarmups(); + 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; - } - - openYomitanSettingsWindow({ - yomitanExt: extension, - getExistingWindow: () => appState.yomitanSettingsWindow, - setWindow: (window: BrowserWindow | null) => { - appState.yomitanSettingsWindow = window; - }, - }); - })().catch((error) => { - logger.error('Failed to open Yomitan settings window.', error); - }); + 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), + })(); } + +const getConfiguredShortcutsHandler = createGetConfiguredShortcutsHandler({ + getResolvedConfig: () => getResolvedConfig(), + defaultConfig: DEFAULT_CONFIG, + resolveConfiguredShortcuts, +}); + +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 { - registerGlobalShortcutsCore({ - shortcuts: getConfiguredShortcuts(), - onToggleVisibleOverlay: () => toggleVisibleOverlay(), - onToggleInvisibleOverlay: () => toggleInvisibleOverlay(), - onOpenYomitanSettings: () => openYomitanSettings(), - isDev, - getMainWindow: () => overlayManager.getMainWindow(), - }); + 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 { }); } +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 { - appendToMpvLog(`[OSD] ${text}`); - showMpvOsdRuntime(appState.mpvClient, text, (line) => { - logger.info(line); - }); + 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,128 +2512,167 @@ const numericShortcutRuntime = createNumericShortcutRuntime({ }); const multiCopySession = numericShortcutRuntime.createSession(); const mineSentenceSession = numericShortcutRuntime.createSession(); +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)', + timeout: 'Copy timeout', + cancelled: 'Cancelled', + }, +}); +const cancelPendingMineSentenceMultipleHandler = createCancelNumericShortcutSessionHandler({ + session: mineSentenceSession, +}); +const startPendingMineSentenceMultipleHandler = createStartNumericShortcutSessionHandler({ + session: mineSentenceSession, + onDigit: (count) => handleMineSentenceDigit(count), + messages: { + prompt: 'Mine how many lines? Press 1-9 (Esc to cancel)', + timeout: 'Mine sentence timeout', + cancelled: 'Cancelled', + }, +}); +const registerOverlayShortcutsHandler = createRegisterOverlayShortcutsHandler({ + overlayShortcutsRuntime, +}); +const unregisterOverlayShortcutsHandler = createUnregisterOverlayShortcutsHandler({ + overlayShortcutsRuntime, +}); +const syncOverlayShortcutsHandler = createSyncOverlayShortcutsHandler({ + overlayShortcutsRuntime, +}); +const refreshOverlayShortcutsHandler = createRefreshOverlayShortcutsHandler({ + overlayShortcutsRuntime, +}); async function triggerSubsyncFromConfig(): Promise { await subsyncRuntime.triggerFromConfig(); } function cancelPendingMultiCopy(): void { - multiCopySession.cancel(); + cancelPendingMultiCopyHandler(); } function startPendingMultiCopy(timeoutMs: number): void { - multiCopySession.start({ - timeoutMs, - onDigit: (count) => handleMultiCopyDigit(count), - messages: { - prompt: 'Copy how many lines? Press 1-9 (Esc to cancel)', - timeout: 'Copy timeout', - cancelled: 'Cancelled', - }, - }); + startPendingMultiCopyHandler(timeoutMs); } function handleMultiCopyDigit(count: number): void { - handleMultiCopyDigitCore(count, { - subtitleTimingTracker: appState.subtitleTimingTracker, - writeClipboardText: (text) => clipboard.writeText(text), - showMpvOsd: (text) => showMpvOsd(text), - }); + handleMultiCopyDigitHandler(count); } function copyCurrentSubtitle(): void { - copyCurrentSubtitleCore({ - subtitleTimingTracker: appState.subtitleTimingTracker, - writeClipboardText: (text) => clipboard.writeText(text), - showMpvOsd: (text) => showMpvOsd(text), - }); + 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) => { + logger.error(message, err); + }, + onCardsMined: (cards) => { + appState.immersionTracker?.recordCardsMined(cards); + }, + handleMineSentenceDigitCore, +}); + async function updateLastCardFromClipboard(): Promise { - await updateLastCardFromClipboardCore({ - ankiIntegration: appState.ankiIntegration, - readClipboardText: () => clipboard.readText(), - showMpvOsd: (text) => showMpvOsd(text), - }); + await updateLastCardFromClipboardHandler(); } async function refreshKnownWordCache(): Promise { - if (!appState.ankiIntegration) { - throw new Error('AnkiConnect integration not enabled'); - } - - await appState.ankiIntegration.refreshKnownWordCache(); + await refreshKnownWordCacheHandler(); } async function triggerFieldGrouping(): Promise { - await triggerFieldGroupingCore({ - ankiIntegration: appState.ankiIntegration, - showMpvOsd: (text) => showMpvOsd(text), - }); + await triggerFieldGroupingHandler(); } async function markLastCardAsAudioCard(): Promise { - await markLastCardAsAudioCardCore({ - ankiIntegration: appState.ankiIntegration, - showMpvOsd: (text) => showMpvOsd(text), - }); + await markLastCardAsAudioCardHandler(); } async function mineSentenceCard(): Promise { - const created = await mineSentenceCardCore({ - ankiIntegration: appState.ankiIntegration, - mpvClient: appState.mpvClient, - showMpvOsd: (text) => showMpvOsd(text), - }); - if (created) { - appState.immersionTracker?.recordCardsMined(1); - } + await mineSentenceCardHandler(); } function cancelPendingMineSentenceMultiple(): void { - mineSentenceSession.cancel(); + cancelPendingMineSentenceMultipleHandler(); } function startPendingMineSentenceMultiple(timeoutMs: number): void { - mineSentenceSession.start({ - timeoutMs, - onDigit: (count) => handleMineSentenceDigit(count), - messages: { - prompt: 'Mine how many lines? Press 1-9 (Esc to cancel)', - timeout: 'Mine sentence timeout', - cancelled: 'Cancelled', - }, - }); + startPendingMineSentenceMultipleHandler(timeoutMs); } function handleMineSentenceDigit(count: number): void { - handleMineSentenceDigitCore(count, { - subtitleTimingTracker: appState.subtitleTimingTracker, - ankiIntegration: appState.ankiIntegration, - getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, - showMpvOsd: (text) => showMpvOsd(text), - logError: (message, err) => { - logger.error(message, err); - }, - onCardsMined: (cards) => { - appState.immersionTracker?.recordCardsMined(cards); - }, - }); + 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 { diff --git a/src/main/runtime/anki-actions.test.ts b/src/main/runtime/anki-actions.test.ts new file mode 100644 index 0000000..1c32ab0 --- /dev/null +++ b/src/main/runtime/anki-actions.test.ts @@ -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']); +}); diff --git a/src/main/runtime/anki-actions.ts b/src/main/runtime/anki-actions.ts new file mode 100644 index 0000000..443a918 --- /dev/null +++ b/src/main/runtime/anki-actions.ts @@ -0,0 +1,90 @@ +type AnkiIntegrationLike = { + refreshKnownWordCache: () => Promise; +}; + +export function createUpdateLastCardFromClipboardHandler(deps: { + getAnkiIntegration: () => TAnki; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + updateLastCardFromClipboardCore: (options: { + ankiIntegration: TAnki; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + }) => Promise; +}) { + return async (): Promise => { + await deps.updateLastCardFromClipboardCore({ + ankiIntegration: deps.getAnkiIntegration(), + readClipboardText: deps.readClipboardText, + showMpvOsd: deps.showMpvOsd, + }); + }; +} + +export function createRefreshKnownWordCacheHandler(deps: { + getAnkiIntegration: () => AnkiIntegrationLike | null; + missingIntegrationMessage: string; +}) { + return async (): Promise => { + const anki = deps.getAnkiIntegration(); + if (!anki) { + throw new Error(deps.missingIntegrationMessage); + } + await anki.refreshKnownWordCache(); + }; +} + +export function createTriggerFieldGroupingHandler(deps: { + getAnkiIntegration: () => TAnki; + showMpvOsd: (text: string) => void; + triggerFieldGroupingCore: (options: { + ankiIntegration: TAnki; + showMpvOsd: (text: string) => void; + }) => Promise; +}) { + return async (): Promise => { + await deps.triggerFieldGroupingCore({ + ankiIntegration: deps.getAnkiIntegration(), + showMpvOsd: deps.showMpvOsd, + }); + }; +} + +export function createMarkLastCardAsAudioCardHandler(deps: { + getAnkiIntegration: () => TAnki; + showMpvOsd: (text: string) => void; + markLastCardAsAudioCardCore: (options: { + ankiIntegration: TAnki; + showMpvOsd: (text: string) => void; + }) => Promise; +}) { + return async (): Promise => { + await deps.markLastCardAsAudioCardCore({ + ankiIntegration: deps.getAnkiIntegration(), + showMpvOsd: deps.showMpvOsd, + }); + }; +} + +export function createMineSentenceCardHandler(deps: { + getAnkiIntegration: () => TAnki; + getMpvClient: () => TMpv; + showMpvOsd: (text: string) => void; + mineSentenceCardCore: (options: { + ankiIntegration: TAnki; + mpvClient: TMpv; + showMpvOsd: (text: string) => void; + }) => Promise; + recordCardsMined: (count: number) => void; +}) { + return async (): Promise => { + const created = await deps.mineSentenceCardCore({ + ankiIntegration: deps.getAnkiIntegration(), + mpvClient: deps.getMpvClient(), + showMpvOsd: deps.showMpvOsd, + }); + if (created) { + deps.recordCardsMined(1); + } + }; +} diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts new file mode 100644 index 0000000..6d73755 --- /dev/null +++ b/src/main/runtime/cli-command-context.test.ts @@ -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']); +}); diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts new file mode 100644 index 0000000..a644961 --- /dev/null +++ b/src/main/runtime/cli-command-context.ts @@ -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; + 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; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + refreshKnownWordCache: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; + getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus']; + clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken']; + openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup']; + openJellyfinSetup: CliCommandRuntimeServiceContext['openJellyfinSetup']; + getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus']; + retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow']; + runJellyfinCommand: (args: CliArgs) => Promise; + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; + stopApp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => ReturnType; + 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, + }; +} diff --git a/src/main/runtime/global-shortcuts.test.ts b/src/main/runtime/global-shortcuts.test.ts new file mode 100644 index 0000000..54cee83 --- /dev/null +++ b/src/main/runtime/global-shortcuts.test.ts @@ -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']); +}); diff --git a/src/main/runtime/global-shortcuts.ts b/src/main/runtime/global-shortcuts.ts new file mode 100644 index 0000000..4268c76 --- /dev/null +++ b/src/main/runtime/global-shortcuts.ts @@ -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(); + }; +} diff --git a/src/main/runtime/mining-actions.test.ts b/src/main/runtime/mining-actions.test.ts new file mode 100644 index 0000000..9b38884 --- /dev/null +++ b/src/main/runtime/mining-actions.test.ts @@ -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']); +}); diff --git a/src/main/runtime/mining-actions.ts b/src/main/runtime/mining-actions.ts new file mode 100644 index 0000000..7e0c4c4 --- /dev/null +++ b/src/main/runtime/mining-actions.ts @@ -0,0 +1,73 @@ +export function createHandleMultiCopyDigitHandler(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(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( + 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, + }); + }; +} diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts new file mode 100644 index 0000000..ca4156c --- /dev/null +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -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', + ]); +}); diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts new file mode 100644 index 0000000..51cd495 --- /dev/null +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -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: (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; + 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 }) => 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); + }; +} diff --git a/src/main/runtime/mpv-client-runtime-service.test.ts b/src/main/runtime/mpv-client-runtime-service.test.ts new file mode 100644 index 0000000..643338d --- /dev/null +++ b/src/main/runtime/mpv-client-runtime-service.test.ts @@ -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']); +}); diff --git a/src/main/runtime/mpv-client-runtime-service.ts b/src/main/runtime/mpv-client-runtime-service.ts new file mode 100644 index 0000000..009bb2c --- /dev/null +++ b/src/main/runtime/mpv-client-runtime-service.ts @@ -0,0 +1,35 @@ +type MpvClientCtorBaseOptions = { + getResolvedConfig: () => unknown; + autoStartOverlay: boolean; + setOverlayVisible: (visible: boolean) => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isVisibleOverlayVisible: () => boolean; + getReconnectTimer: () => ReturnType | null; + setReconnectTimer: (timer: ReturnType | null) => void; +}; + +type MpvClientLike = { + connect: () => void; +}; + +type MpvClientCtor = new ( + socketPath: string, + options: TOptions, +) => TClient; + +export function createMpvClientRuntimeServiceFactory< + TClient extends MpvClientLike, + TOptions extends MpvClientCtorBaseOptions, +>(deps: { + createClient: MpvClientCtor; + 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; + }; +} diff --git a/src/main/runtime/mpv-osd-log.test.ts b/src/main/runtime/mpv-osd-log.test.ts new file mode 100644 index 0000000..8659d96 --- /dev/null +++ b/src/main/runtime/mpv-osd-log.test.ts @@ -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', + ]); +}); diff --git a/src/main/runtime/mpv-osd-log.ts b/src/main/runtime/mpv-osd-log.ts new file mode 100644 index 0000000..2a88d91 --- /dev/null +++ b/src/main/runtime/mpv-osd-log.ts @@ -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); + }); + }; +} diff --git a/src/main/runtime/mpv-subtitle-render-metrics.test.ts b/src/main/runtime/mpv-subtitle-render-metrics.test.ts new file mode 100644 index 0000000..e7d764a --- /dev/null +++ b/src/main/runtime/mpv-subtitle-render-metrics.test.ts @@ -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); +}); diff --git a/src/main/runtime/mpv-subtitle-render-metrics.ts b/src/main/runtime/mpv-subtitle-render-metrics.ts new file mode 100644 index 0000000..88819a5 --- /dev/null +++ b/src/main/runtime/mpv-subtitle-render-metrics.ts @@ -0,0 +1,18 @@ +import type { MpvSubtitleRenderMetrics } from '../../types'; + +export function createUpdateMpvSubtitleRenderMetricsHandler(deps: { + getCurrentMetrics: () => MpvSubtitleRenderMetrics; + setCurrentMetrics: (metrics: MpvSubtitleRenderMetrics) => void; + applyPatch: ( + current: MpvSubtitleRenderMetrics, + patch: Partial, + ) => { next: MpvSubtitleRenderMetrics; changed: boolean }; + broadcastMetrics: (metrics: MpvSubtitleRenderMetrics) => void; +}) { + return (patch: Partial): void => { + const { next, changed } = deps.applyPatch(deps.getCurrentMetrics(), patch); + if (!changed) return; + deps.setCurrentMetrics(next); + deps.broadcastMetrics(next); + }; +} diff --git a/src/main/runtime/numeric-shortcut-session-handlers.test.ts b/src/main/runtime/numeric-shortcut-session-handlers.test.ts new file mode 100644 index 0000000..a77e899 --- /dev/null +++ b/src/main/runtime/numeric-shortcut-session-handlers.test.ts @@ -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']); +}); diff --git a/src/main/runtime/numeric-shortcut-session-handlers.ts b/src/main/runtime/numeric-shortcut-session-handlers.ts new file mode 100644 index 0000000..e9b85a5 --- /dev/null +++ b/src/main/runtime/numeric-shortcut-session-handlers.ts @@ -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, + }); + }; +} diff --git a/src/main/runtime/overlay-runtime-bootstrap.test.ts b/src/main/runtime/overlay-runtime-bootstrap.test.ts new file mode 100644 index 0000000..afe2228 --- /dev/null +++ b/src/main/runtime/overlay-runtime-bootstrap.test.ts @@ -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']); +}); diff --git a/src/main/runtime/overlay-runtime-bootstrap.ts b/src/main/runtime/overlay-runtime-bootstrap.ts new file mode 100644 index 0000000..e05f7de --- /dev/null +++ b/src/main/runtime/overlay-runtime-bootstrap.ts @@ -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; + getKnownWordCacheStatePath: () => string; +}) => { invisibleOverlayVisible: boolean }; + +export function createInitializeOverlayRuntimeHandler(deps: { + isOverlayRuntimeInitialized: () => boolean; + initializeOverlayRuntimeCore: InitializeOverlayRuntimeCore; + buildOptions: () => Parameters[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(); + }; +} diff --git a/src/main/runtime/overlay-shortcuts-lifecycle.test.ts b/src/main/runtime/overlay-shortcuts-lifecycle.test.ts new file mode 100644 index 0000000..b4fa970 --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-lifecycle.test.ts @@ -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']); +}); diff --git a/src/main/runtime/overlay-shortcuts-lifecycle.ts b/src/main/runtime/overlay-shortcuts-lifecycle.ts new file mode 100644 index 0000000..baf80dd --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-lifecycle.ts @@ -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(); + }; +} diff --git a/src/main/runtime/overlay-window-layout.test.ts b/src/main/runtime/overlay-window-layout.test.ts new file mode 100644 index 0000000..8eeab17 --- /dev/null +++ b/src/main/runtime/overlay-window-layout.test.ts @@ -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']); +}); diff --git a/src/main/runtime/overlay-window-layout.ts b/src/main/runtime/overlay-window-layout.ts new file mode 100644 index 0000000..4d62f44 --- /dev/null +++ b/src/main/runtime/overlay-window-layout.ts @@ -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, + }); + }; +} diff --git a/src/main/runtime/startup-warmups.test.ts b/src/main/runtime/startup-warmups.test.ts new file mode 100644 index 0000000..e0cce03 --- /dev/null +++ b/src/main/runtime/startup-warmups.test.ts @@ -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', + ]); +}); diff --git a/src/main/runtime/startup-warmups.ts b/src/main/runtime/startup-warmups.ts new file mode 100644 index 0000000..87996ac --- /dev/null +++ b/src/main/runtime/startup-warmups.ts @@ -0,0 +1,50 @@ +export function createLaunchBackgroundWarmupTaskHandler(deps: { + now: () => number; + logDebug: (message: string) => void; + logWarn: (message: string) => void; +}) { + return (label: string, task: () => Promise): 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; + createMecabTokenizerAndCheck: () => Promise; + ensureYomitanExtensionLoaded: () => Promise; + prewarmSubtitleDictionaries: () => Promise; + shouldAutoConnectJellyfinRemote: () => boolean; + startJellyfinRemoteSession: () => Promise; +}) { + 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(); + }); + } + }; +} diff --git a/src/main/runtime/tray-lifecycle.test.ts b/src/main/runtime/tray-lifecycle.test.ts new file mode 100644 index 0000000..551e69a --- /dev/null +++ b/src/main/runtime/tray-lifecycle.test.ts @@ -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); +}); diff --git a/src/main/runtime/tray-lifecycle.ts b/src/main/runtime/tray-lifecycle.ts new file mode 100644 index 0000000..fc78295 --- /dev/null +++ b/src/main/runtime/tray-lifecycle.ts @@ -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); + }; +} diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts new file mode 100644 index 0000000..2e87262 --- /dev/null +++ b/src/main/runtime/tray-runtime.test.ts @@ -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']); +}); diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts new file mode 100644 index 0000000..50362f6 --- /dev/null +++ b/src/main/runtime/tray-runtime.ts @@ -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, + }, + ]; +} diff --git a/src/main/runtime/yomitan-settings-opener.test.ts b/src/main/runtime/yomitan-settings-opener.test.ts new file mode 100644 index 0000000..2bc9728 --- /dev/null +++ b/src/main/runtime/yomitan-settings-opener.test.ts @@ -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); +}); diff --git a/src/main/runtime/yomitan-settings-opener.ts b/src/main/runtime/yomitan-settings-opener.ts new file mode 100644 index 0000000..7d8b88a --- /dev/null +++ b/src/main/runtime/yomitan-settings-opener.ts @@ -0,0 +1,32 @@ +type YomitanExtensionLike = unknown; +type BrowserWindowLike = unknown; + +export function createOpenYomitanSettingsHandler(deps: { + ensureYomitanExtensionLoaded: () => Promise; + 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); + }); + }; +}