mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor: split main runtime wrappers into focused modules
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
590
src/main.ts
590
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<Extension | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await updateLastCardFromClipboardCore({
|
||||
ankiIntegration: appState.ankiIntegration,
|
||||
readClipboardText: () => clipboard.readText(),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
await updateLastCardFromClipboardHandler();
|
||||
}
|
||||
|
||||
async function refreshKnownWordCache(): Promise<void> {
|
||||
if (!appState.ankiIntegration) {
|
||||
throw new Error('AnkiConnect integration not enabled');
|
||||
}
|
||||
|
||||
await appState.ankiIntegration.refreshKnownWordCache();
|
||||
await refreshKnownWordCacheHandler();
|
||||
}
|
||||
|
||||
async function triggerFieldGrouping(): Promise<void> {
|
||||
await triggerFieldGroupingCore({
|
||||
ankiIntegration: appState.ankiIntegration,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
await triggerFieldGroupingHandler();
|
||||
}
|
||||
|
||||
async function markLastCardAsAudioCard(): Promise<void> {
|
||||
await markLastCardAsAudioCardCore({
|
||||
ankiIntegration: appState.ankiIntegration,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
await markLastCardAsAudioCardHandler();
|
||||
}
|
||||
|
||||
async function mineSentenceCard(): Promise<void> {
|
||||
const created = await mineSentenceCardCore({
|
||||
ankiIntegration: appState.ankiIntegration,
|
||||
mpvClient: appState.mpvClient,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
if (created) {
|
||||
appState.immersionTracker?.recordCardsMined(1);
|
||||
}
|
||||
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 {
|
||||
|
||||
89
src/main/runtime/anki-actions.test.ts
Normal file
89
src/main/runtime/anki-actions.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createMarkLastCardAsAudioCardHandler,
|
||||
createMineSentenceCardHandler,
|
||||
createRefreshKnownWordCacheHandler,
|
||||
createTriggerFieldGroupingHandler,
|
||||
createUpdateLastCardFromClipboardHandler,
|
||||
} from './anki-actions';
|
||||
|
||||
test('update last card handler forwards integration/clipboard/osd deps', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const updateLastCard = createUpdateLastCardFromClipboardHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
readClipboardText: () => 'clipboard-value',
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
updateLastCardFromClipboardCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
assert.equal(options.readClipboardText(), 'clipboard-value');
|
||||
options.showMpvOsd('ok');
|
||||
calls.push('core');
|
||||
},
|
||||
});
|
||||
|
||||
await updateLastCard();
|
||||
assert.deepEqual(calls, ['osd:ok', 'core']);
|
||||
});
|
||||
|
||||
test('refresh known word cache handler throws when Anki integration missing', async () => {
|
||||
const refresh = createRefreshKnownWordCacheHandler({
|
||||
getAnkiIntegration: () => null,
|
||||
missingIntegrationMessage: 'AnkiConnect integration not enabled',
|
||||
});
|
||||
|
||||
await assert.rejects(() => refresh(), /AnkiConnect integration not enabled/);
|
||||
});
|
||||
|
||||
test('trigger and mark handlers delegate to core services', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
triggerFieldGroupingCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
options.showMpvOsd('group');
|
||||
calls.push('group-core');
|
||||
},
|
||||
});
|
||||
const markAudio = createMarkLastCardAsAudioCardHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
markLastCardAsAudioCardCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
options.showMpvOsd('mark');
|
||||
calls.push('mark-core');
|
||||
},
|
||||
});
|
||||
|
||||
await triggerFieldGrouping();
|
||||
await markAudio();
|
||||
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
|
||||
});
|
||||
|
||||
test('mine sentence handler records mined cards only when core returns true', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const mpvClient = {};
|
||||
let created = false;
|
||||
const mineSentenceCard = createMineSentenceCardHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
getMpvClient: () => mpvClient,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
mineSentenceCardCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
assert.equal(options.mpvClient, mpvClient);
|
||||
options.showMpvOsd('mine');
|
||||
return created;
|
||||
},
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
});
|
||||
|
||||
created = false;
|
||||
await mineSentenceCard();
|
||||
created = true;
|
||||
await mineSentenceCard();
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
|
||||
});
|
||||
90
src/main/runtime/anki-actions.ts
Normal file
90
src/main/runtime/anki-actions.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
type AnkiIntegrationLike = {
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createUpdateLastCardFromClipboardHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
readClipboardText: () => string;
|
||||
showMpvOsd: (text: string) => void;
|
||||
updateLastCardFromClipboardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
readClipboardText: () => string;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
await deps.updateLastCardFromClipboardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
readClipboardText: deps.readClipboardText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createRefreshKnownWordCacheHandler(deps: {
|
||||
getAnkiIntegration: () => AnkiIntegrationLike | null;
|
||||
missingIntegrationMessage: string;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const anki = deps.getAnkiIntegration();
|
||||
if (!anki) {
|
||||
throw new Error(deps.missingIntegrationMessage);
|
||||
}
|
||||
await anki.refreshKnownWordCache();
|
||||
};
|
||||
}
|
||||
|
||||
export function createTriggerFieldGroupingHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
triggerFieldGroupingCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
await deps.triggerFieldGroupingCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createMarkLastCardAsAudioCardHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
markLastCardAsAudioCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
await deps.markLastCardAsAudioCardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
getMpvClient: () => TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
mpvClient: TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number) => void;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const created = await deps.mineSentenceCardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
mpvClient: deps.getMpvClient(),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
if (created) {
|
||||
deps.recordCardsMined(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
96
src/main/runtime/cli-command-context.test.ts
Normal file
96
src/main/runtime/cli-command-context.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCliCommandContext } from './cli-command-context';
|
||||
|
||||
function createDeps() {
|
||||
let socketPath = '/tmp/mpv.sock';
|
||||
const logs: string[] = [];
|
||||
const browserErrors: string[] = [];
|
||||
|
||||
return {
|
||||
deps: {
|
||||
getSocketPath: () => socketPath,
|
||||
setSocketPath: (value: string) => {
|
||||
socketPath = value;
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
showOsd: () => {},
|
||||
texthookerService: {} as never,
|
||||
getTexthookerPort: () => 6677,
|
||||
setTexthookerPort: () => {},
|
||||
shouldOpenBrowser: () => true,
|
||||
openExternal: async () => {},
|
||||
logBrowserOpenError: (url: string) => browserErrors.push(url),
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
toggleInvisibleOverlay: () => {},
|
||||
setVisibleOverlay: () => {},
|
||||
setInvisibleOverlay: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
mineSentenceCard: async () => {},
|
||||
startPendingMineSentenceMultiple: () => {},
|
||||
updateLastCardFromClipboard: async () => {},
|
||||
refreshKnownWordCache: async () => {},
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
getAnilistStatus: () => ({} as never),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
openJellyfinSetup: () => {},
|
||||
getAnilistQueueStatus: () => ({} as never),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
runJellyfinCommand: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
stopApp: () => {},
|
||||
hasMainWindow: () => true,
|
||||
getMultiCopyTimeoutMs: () => 1000,
|
||||
schedule: (fn: () => void) => setTimeout(fn, 0),
|
||||
logInfo: (message: string) => {
|
||||
logs.push(`i:${message}`);
|
||||
},
|
||||
logWarn: (message: string) => {
|
||||
logs.push(`w:${message}`);
|
||||
},
|
||||
logError: (message: string) => {
|
||||
logs.push(`e:${message}`);
|
||||
},
|
||||
},
|
||||
getLogs: () => logs,
|
||||
getBrowserErrors: () => browserErrors,
|
||||
};
|
||||
}
|
||||
|
||||
test('cli command context proxies socket path getters/setters', () => {
|
||||
const { deps } = createDeps();
|
||||
const context = createCliCommandContext(deps);
|
||||
assert.equal(context.getSocketPath(), '/tmp/mpv.sock');
|
||||
context.setSocketPath('/tmp/next.sock');
|
||||
assert.equal(context.getSocketPath(), '/tmp/next.sock');
|
||||
});
|
||||
|
||||
test('cli command context openInBrowser reports failures', async () => {
|
||||
const { deps, getBrowserErrors } = createDeps();
|
||||
deps.openExternal = async () => {
|
||||
throw new Error('no browser');
|
||||
};
|
||||
const context = createCliCommandContext(deps);
|
||||
context.openInBrowser('https://example.com');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(getBrowserErrors(), ['https://example.com']);
|
||||
});
|
||||
|
||||
test('cli command context log methods map to deps loggers', () => {
|
||||
const { deps, getLogs } = createDeps();
|
||||
const context = createCliCommandContext(deps);
|
||||
context.log('info');
|
||||
context.warn('warn');
|
||||
context.error('error', new Error('x'));
|
||||
assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']);
|
||||
});
|
||||
106
src/main/runtime/cli-command-context.ts
Normal file
106
src/main/runtime/cli-command-context.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type {
|
||||
CliCommandRuntimeServiceContext,
|
||||
CliCommandRuntimeServiceContextHandlers,
|
||||
} from '../cli-runtime';
|
||||
|
||||
type MpvClientLike = CliCommandRuntimeServiceContext['getClient'] extends () => infer T ? T : never;
|
||||
|
||||
export type CliCommandContextFactoryDeps = {
|
||||
getSocketPath: () => string;
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getMpvClient: () => MpvClientLike;
|
||||
showOsd: (text: string) => void;
|
||||
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
|
||||
getTexthookerPort: () => number;
|
||||
setTexthookerPort: (port: number) => void;
|
||||
shouldOpenBrowser: () => boolean;
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
logBrowserOpenError: (url: string, error: unknown) => void;
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
setInvisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
||||
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
|
||||
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
|
||||
openJellyfinSetup: CliCommandRuntimeServiceContext['openJellyfinSetup'];
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
stopApp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
};
|
||||
|
||||
export function createCliCommandContext(
|
||||
deps: CliCommandContextFactoryDeps,
|
||||
): CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers {
|
||||
return {
|
||||
getSocketPath: deps.getSocketPath,
|
||||
setSocketPath: deps.setSocketPath,
|
||||
getClient: deps.getMpvClient,
|
||||
showOsd: deps.showOsd,
|
||||
texthookerService: deps.texthookerService,
|
||||
getTexthookerPort: deps.getTexthookerPort,
|
||||
setTexthookerPort: deps.setTexthookerPort,
|
||||
shouldOpenBrowser: deps.shouldOpenBrowser,
|
||||
openInBrowser: (url: string) => {
|
||||
void deps.openExternal(url).catch((error) => {
|
||||
deps.logBrowserOpenError(url, error);
|
||||
});
|
||||
},
|
||||
isOverlayInitialized: deps.isOverlayInitialized,
|
||||
initializeOverlay: deps.initializeOverlay,
|
||||
toggleVisibleOverlay: deps.toggleVisibleOverlay,
|
||||
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
|
||||
setVisibleOverlay: deps.setVisibleOverlay,
|
||||
setInvisibleOverlay: deps.setInvisibleOverlay,
|
||||
copyCurrentSubtitle: deps.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: deps.startPendingMultiCopy,
|
||||
mineSentenceCard: deps.mineSentenceCard,
|
||||
startPendingMineSentenceMultiple: deps.startPendingMineSentenceMultiple,
|
||||
updateLastCardFromClipboard: deps.updateLastCardFromClipboard,
|
||||
refreshKnownWordCache: deps.refreshKnownWordCache,
|
||||
triggerFieldGrouping: deps.triggerFieldGrouping,
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
|
||||
getAnilistStatus: deps.getAnilistStatus,
|
||||
clearAnilistToken: deps.clearAnilistToken,
|
||||
openAnilistSetup: deps.openAnilistSetup,
|
||||
openJellyfinSetup: deps.openJellyfinSetup,
|
||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
stopApp: deps.stopApp,
|
||||
hasMainWindow: deps.hasMainWindow,
|
||||
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
|
||||
schedule: deps.schedule,
|
||||
log: deps.logInfo,
|
||||
warn: deps.logWarn,
|
||||
error: deps.logError,
|
||||
};
|
||||
}
|
||||
85
src/main/runtime/global-shortcuts.test.ts
Normal file
85
src/main/runtime/global-shortcuts.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGetConfiguredShortcutsHandler,
|
||||
createRefreshGlobalAndOverlayShortcutsHandler,
|
||||
createRegisterGlobalShortcutsHandler,
|
||||
} from './global-shortcuts';
|
||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
|
||||
function createShortcuts(): ConfiguredShortcuts {
|
||||
return {
|
||||
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
|
||||
toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I',
|
||||
copySubtitle: 's',
|
||||
copySubtitleMultiple: 'CommandOrControl+s',
|
||||
updateLastCardFromClipboard: 'c',
|
||||
triggerFieldGrouping: null,
|
||||
triggerSubsync: null,
|
||||
mineSentence: 'q',
|
||||
mineSentenceMultiple: 'w',
|
||||
multiCopyTimeoutMs: 5000,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('configured shortcuts handler resolves from current config', () => {
|
||||
const calls: string[] = [];
|
||||
const config = {} as never;
|
||||
const defaultConfig = {} as never;
|
||||
const shortcuts = createShortcuts();
|
||||
const getConfiguredShortcuts = createGetConfiguredShortcutsHandler({
|
||||
getResolvedConfig: () => config,
|
||||
defaultConfig,
|
||||
resolveConfiguredShortcuts: (nextConfig, nextDefaultConfig) => {
|
||||
calls.push('resolve');
|
||||
assert.equal(nextConfig, config);
|
||||
assert.equal(nextDefaultConfig, defaultConfig);
|
||||
return shortcuts;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(getConfiguredShortcuts(), shortcuts);
|
||||
assert.deepEqual(calls, ['resolve']);
|
||||
});
|
||||
|
||||
test('register global shortcuts handler passes through callbacks and shortcuts', () => {
|
||||
const calls: string[] = [];
|
||||
const shortcuts = createShortcuts();
|
||||
const mainWindow = {} as never;
|
||||
const registerGlobalShortcuts = createRegisterGlobalShortcutsHandler({
|
||||
getConfiguredShortcuts: () => shortcuts,
|
||||
registerGlobalShortcutsCore: (options) => {
|
||||
calls.push('register');
|
||||
assert.equal(options.shortcuts, shortcuts);
|
||||
assert.equal(options.isDev, true);
|
||||
assert.equal(options.getMainWindow(), mainWindow);
|
||||
options.onToggleVisibleOverlay();
|
||||
options.onToggleInvisibleOverlay();
|
||||
options.onOpenYomitanSettings();
|
||||
},
|
||||
onToggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
onToggleInvisibleOverlay: () => calls.push('toggle-invisible'),
|
||||
onOpenYomitanSettings: () => calls.push('open-yomitan'),
|
||||
isDev: true,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
registerGlobalShortcuts();
|
||||
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']);
|
||||
});
|
||||
|
||||
test('refresh global and overlay shortcuts unregisters then re-registers', () => {
|
||||
const calls: string[] = [];
|
||||
const refresh = createRefreshGlobalAndOverlayShortcutsHandler({
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister'),
|
||||
registerGlobalShortcuts: () => calls.push('register'),
|
||||
syncOverlayShortcuts: () => calls.push('sync-overlay'),
|
||||
});
|
||||
|
||||
refresh();
|
||||
assert.deepEqual(calls, ['unregister', 'register', 'sync-overlay']);
|
||||
});
|
||||
48
src/main/runtime/global-shortcuts.ts
Normal file
48
src/main/runtime/global-shortcuts.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Config } from '../../types';
|
||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import type { RegisterGlobalShortcutsServiceOptions } from '../../core/services/shortcut';
|
||||
|
||||
export function createGetConfiguredShortcutsHandler(deps: {
|
||||
getResolvedConfig: () => Config;
|
||||
defaultConfig: Config;
|
||||
resolveConfiguredShortcuts: (
|
||||
config: Config,
|
||||
defaultConfig: Config,
|
||||
) => ConfiguredShortcuts;
|
||||
}) {
|
||||
return (): ConfiguredShortcuts =>
|
||||
deps.resolveConfiguredShortcuts(deps.getResolvedConfig(), deps.defaultConfig);
|
||||
}
|
||||
|
||||
export function createRegisterGlobalShortcutsHandler(deps: {
|
||||
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
|
||||
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
|
||||
onToggleVisibleOverlay: () => void;
|
||||
onToggleInvisibleOverlay: () => void;
|
||||
onOpenYomitanSettings: () => void;
|
||||
isDev: boolean;
|
||||
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.registerGlobalShortcutsCore({
|
||||
shortcuts: deps.getConfiguredShortcuts(),
|
||||
onToggleVisibleOverlay: deps.onToggleVisibleOverlay,
|
||||
onToggleInvisibleOverlay: deps.onToggleInvisibleOverlay,
|
||||
onOpenYomitanSettings: deps.onOpenYomitanSettings,
|
||||
isDev: deps.isDev,
|
||||
getMainWindow: deps.getMainWindow,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createRefreshGlobalAndOverlayShortcutsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.registerGlobalShortcuts();
|
||||
deps.syncOverlayShortcuts();
|
||||
};
|
||||
}
|
||||
70
src/main/runtime/mining-actions.test.ts
Normal file
70
src/main/runtime/mining-actions.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCopyCurrentSubtitleHandler,
|
||||
createHandleMineSentenceDigitHandler,
|
||||
createHandleMultiCopyDigitHandler,
|
||||
} from './mining-actions';
|
||||
|
||||
test('multi-copy digit handler forwards tracker/clipboard/osd deps', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {};
|
||||
const handleMultiCopyDigit = createHandleMultiCopyDigitHandler({
|
||||
getSubtitleTimingTracker: () => tracker,
|
||||
writeClipboardText: (text) => calls.push(`clipboard:${text}`),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
handleMultiCopyDigitCore: (count, options) => {
|
||||
assert.equal(count, 3);
|
||||
assert.equal(options.subtitleTimingTracker, tracker);
|
||||
options.writeClipboardText('copied');
|
||||
options.showMpvOsd('done');
|
||||
},
|
||||
});
|
||||
|
||||
handleMultiCopyDigit(3);
|
||||
assert.deepEqual(calls, ['clipboard:copied', 'osd:done']);
|
||||
});
|
||||
|
||||
test('copy current subtitle handler forwards tracker/clipboard/osd deps', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {};
|
||||
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
|
||||
getSubtitleTimingTracker: () => tracker,
|
||||
writeClipboardText: (text) => calls.push(`clipboard:${text}`),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
copyCurrentSubtitleCore: (options) => {
|
||||
assert.equal(options.subtitleTimingTracker, tracker);
|
||||
options.writeClipboardText('subtitle');
|
||||
options.showMpvOsd('copied');
|
||||
},
|
||||
});
|
||||
|
||||
copyCurrentSubtitle();
|
||||
assert.deepEqual(calls, ['clipboard:subtitle', 'osd:copied']);
|
||||
});
|
||||
|
||||
test('mine sentence digit handler forwards all dependencies', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {};
|
||||
const integration = {};
|
||||
const handleMineSentenceDigit = createHandleMineSentenceDigitHandler({
|
||||
getSubtitleTimingTracker: () => tracker,
|
||||
getAnkiIntegration: () => integration,
|
||||
getCurrentSecondarySubText: () => 'secondary',
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
logError: (message) => calls.push(`err:${message}`),
|
||||
onCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
handleMineSentenceDigitCore: (count, options) => {
|
||||
assert.equal(count, 4);
|
||||
assert.equal(options.subtitleTimingTracker, tracker);
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
assert.equal(options.getCurrentSecondarySubText(), 'secondary');
|
||||
options.showMpvOsd('mine');
|
||||
options.logError('boom', new Error('x'));
|
||||
options.onCardsMined(2);
|
||||
},
|
||||
});
|
||||
|
||||
handleMineSentenceDigit(4);
|
||||
assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']);
|
||||
});
|
||||
73
src/main/runtime/mining-actions.ts
Normal file
73
src/main/runtime/mining-actions.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export function createHandleMultiCopyDigitHandler<TSubtitleTimingTracker>(deps: {
|
||||
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
handleMultiCopyDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
subtitleTimingTracker: TSubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
},
|
||||
) => void;
|
||||
}) {
|
||||
return (count: number): void => {
|
||||
deps.handleMultiCopyDigitCore(count, {
|
||||
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
|
||||
writeClipboardText: deps.writeClipboardText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createCopyCurrentSubtitleHandler<TSubtitleTimingTracker>(deps: {
|
||||
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
copyCurrentSubtitleCore: (options: {
|
||||
subtitleTimingTracker: TSubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.copyCurrentSubtitleCore({
|
||||
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
|
||||
writeClipboardText: deps.writeClipboardText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMineSentenceDigitHandler<TSubtitleTimingTracker, TAnkiIntegration>(
|
||||
deps: {
|
||||
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
|
||||
getAnkiIntegration: () => TAnkiIntegration;
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
subtitleTimingTracker: TSubtitleTimingTracker;
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => void;
|
||||
},
|
||||
) {
|
||||
return (count: number): void => {
|
||||
deps.handleMineSentenceDigitCore(count, {
|
||||
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
getCurrentSecondarySubText: deps.getCurrentSecondarySubText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
logError: deps.logError,
|
||||
onCardsMined: deps.onCardsMined,
|
||||
});
|
||||
};
|
||||
}
|
||||
79
src/main/runtime/mpv-client-event-bindings.test.ts
Normal file
79
src/main/runtime/mpv-client-event-bindings.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createBindMpvClientEventHandlers,
|
||||
createHandleMpvConnectionChangeHandler,
|
||||
createHandleMpvSubtitleTimingHandler,
|
||||
} from './mpv-client-event-bindings';
|
||||
|
||||
test('mpv connection handler reports stop and quits when disconnect guard passes', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvConnectionChangeHandler({
|
||||
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||
hasInitialJellyfinPlayArg: () => true,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => true,
|
||||
scheduleQuitCheck: (callback) => {
|
||||
calls.push('schedule');
|
||||
callback();
|
||||
},
|
||||
isMpvConnected: () => false,
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
handler({ connected: false });
|
||||
assert.deepEqual(calls, ['report-stop', 'schedule', 'quit']);
|
||||
});
|
||||
|
||||
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleTimingHandler({
|
||||
recordImmersionSubtitleLine: () => calls.push('immersion'),
|
||||
hasSubtitleTimingTracker: () => true,
|
||||
recordSubtitleTiming: () => calls.push('timing'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {
|
||||
calls.push('post-watch');
|
||||
},
|
||||
logError: () => calls.push('error'),
|
||||
});
|
||||
|
||||
handler({ text: ' ', start: 1, end: 2 });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('mpv event bindings register all expected events', () => {
|
||||
const seenEvents: string[] = [];
|
||||
const bindHandlers = createBindMpvClientEventHandlers({
|
||||
onConnectionChange: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
onSubtitleAssChange: () => {},
|
||||
onSecondarySubtitleChange: () => {},
|
||||
onSubtitleTiming: () => {},
|
||||
onMediaPathChange: () => {},
|
||||
onMediaTitleChange: () => {},
|
||||
onTimePosChange: () => {},
|
||||
onPauseChange: () => {},
|
||||
onSubtitleMetricsChange: () => {},
|
||||
onSecondarySubtitleVisibility: () => {},
|
||||
});
|
||||
|
||||
bindHandlers({
|
||||
on: (event) => {
|
||||
seenEvents.push(event);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(seenEvents, [
|
||||
'connection-change',
|
||||
'subtitle-change',
|
||||
'subtitle-ass-change',
|
||||
'secondary-subtitle-change',
|
||||
'subtitle-timing',
|
||||
'media-path-change',
|
||||
'media-title-change',
|
||||
'time-pos-change',
|
||||
'pause-change',
|
||||
'subtitle-metrics-change',
|
||||
'secondary-subtitle-visibility',
|
||||
]);
|
||||
});
|
||||
84
src/main/runtime/mpv-client-event-bindings.ts
Normal file
84
src/main/runtime/mpv-client-event-bindings.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
type MpvBindingEventName =
|
||||
| 'connection-change'
|
||||
| 'subtitle-change'
|
||||
| 'subtitle-ass-change'
|
||||
| 'secondary-subtitle-change'
|
||||
| 'subtitle-timing'
|
||||
| 'media-path-change'
|
||||
| 'media-title-change'
|
||||
| 'time-pos-change'
|
||||
| 'pause-change'
|
||||
| 'subtitle-metrics-change'
|
||||
| 'secondary-subtitle-visibility';
|
||||
|
||||
type MpvEventClient = {
|
||||
on: <K extends MpvBindingEventName>(event: K, handler: (payload: any) => void) => void;
|
||||
};
|
||||
|
||||
export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
hasInitialJellyfinPlayArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
isQuitOnDisconnectArmed: () => boolean;
|
||||
scheduleQuitCheck: (callback: () => void) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
quitApp: () => void;
|
||||
}) {
|
||||
return ({ connected }: { connected: boolean }): void => {
|
||||
if (connected) return;
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
if (!deps.hasInitialJellyfinPlayArg()) return;
|
||||
if (deps.isOverlayRuntimeInitialized()) return;
|
||||
if (!deps.isQuitOnDisconnectArmed()) return;
|
||||
deps.scheduleQuitCheck(() => {
|
||||
if (deps.isMpvConnected()) return;
|
||||
deps.quitApp();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMpvSubtitleTimingHandler(deps: {
|
||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
||||
hasSubtitleTimingTracker: () => boolean;
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return ({ text, start, end }: { text: string; start: number; end: number }): void => {
|
||||
if (!text.trim()) return;
|
||||
deps.recordImmersionSubtitleLine(text, start, end);
|
||||
if (!deps.hasSubtitleTimingTracker()) return;
|
||||
deps.recordSubtitleTiming(text, start, end);
|
||||
void deps.maybeRunAnilistPostWatchUpdate().catch((error) => {
|
||||
deps.logError('AniList post-watch update failed unexpectedly', error);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createBindMpvClientEventHandlers(deps: {
|
||||
onConnectionChange: (payload: { connected: boolean }) => void;
|
||||
onSubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleAssChange: (payload: { text: string }) => void;
|
||||
onSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||
onMediaPathChange: (payload: { path: string }) => void;
|
||||
onMediaTitleChange: (payload: { title: string }) => void;
|
||||
onTimePosChange: (payload: { time: number }) => void;
|
||||
onPauseChange: (payload: { paused: boolean }) => void;
|
||||
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;
|
||||
onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
|
||||
}) {
|
||||
return (mpvClient: MpvEventClient): void => {
|
||||
mpvClient.on('connection-change', deps.onConnectionChange);
|
||||
mpvClient.on('subtitle-change', deps.onSubtitleChange);
|
||||
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
|
||||
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
|
||||
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
|
||||
mpvClient.on('media-path-change', deps.onMediaPathChange);
|
||||
mpvClient.on('media-title-change', deps.onMediaTitleChange);
|
||||
mpvClient.on('time-pos-change', deps.onTimePosChange);
|
||||
mpvClient.on('pause-change', deps.onPauseChange);
|
||||
mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange);
|
||||
mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility);
|
||||
};
|
||||
}
|
||||
40
src/main/runtime/mpv-client-runtime-service.test.ts
Normal file
40
src/main/runtime/mpv-client-runtime-service.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createMpvClientRuntimeServiceFactory } from './mpv-client-runtime-service';
|
||||
|
||||
test('mpv runtime service factory constructs client, binds handlers, and connects', () => {
|
||||
const calls: string[] = [];
|
||||
let constructedSocketPath = '';
|
||||
|
||||
class FakeClient {
|
||||
connect(): void {
|
||||
calls.push('connect');
|
||||
}
|
||||
constructor(socketPath: string) {
|
||||
constructedSocketPath = socketPath;
|
||||
calls.push('construct');
|
||||
}
|
||||
}
|
||||
|
||||
const createRuntimeService = createMpvClientRuntimeServiceFactory({
|
||||
createClient: FakeClient,
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
options: {
|
||||
getResolvedConfig: () => ({}),
|
||||
autoStartOverlay: true,
|
||||
setOverlayVisible: () => {},
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
getReconnectTimer: () => null,
|
||||
setReconnectTimer: () => {},
|
||||
},
|
||||
bindEventHandlers: () => {
|
||||
calls.push('bind');
|
||||
},
|
||||
});
|
||||
|
||||
const client = createRuntimeService();
|
||||
assert.ok(client instanceof FakeClient);
|
||||
assert.equal(constructedSocketPath, '/tmp/mpv.sock');
|
||||
assert.deepEqual(calls, ['construct', 'bind', 'connect']);
|
||||
});
|
||||
35
src/main/runtime/mpv-client-runtime-service.ts
Normal file
35
src/main/runtime/mpv-client-runtime-service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
type MpvClientCtorBaseOptions = {
|
||||
getResolvedConfig: () => unknown;
|
||||
autoStartOverlay: boolean;
|
||||
setOverlayVisible: (visible: boolean) => void;
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
connect: () => void;
|
||||
};
|
||||
|
||||
type MpvClientCtor<TClient extends MpvClientLike, TOptions extends MpvClientCtorBaseOptions> = new (
|
||||
socketPath: string,
|
||||
options: TOptions,
|
||||
) => TClient;
|
||||
|
||||
export function createMpvClientRuntimeServiceFactory<
|
||||
TClient extends MpvClientLike,
|
||||
TOptions extends MpvClientCtorBaseOptions,
|
||||
>(deps: {
|
||||
createClient: MpvClientCtor<TClient, TOptions>;
|
||||
socketPath: string;
|
||||
options: TOptions;
|
||||
bindEventHandlers: (client: TClient) => void;
|
||||
}) {
|
||||
return (): TClient => {
|
||||
const mpvClient = new deps.createClient(deps.socketPath, deps.options);
|
||||
deps.bindEventHandlers(mpvClient);
|
||||
mpvClient.connect();
|
||||
return mpvClient;
|
||||
};
|
||||
}
|
||||
65
src/main/runtime/mpv-osd-log.test.ts
Normal file
65
src/main/runtime/mpv-osd-log.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-log';
|
||||
|
||||
test('append mpv log writes timestamped message', () => {
|
||||
const calls: string[] = [];
|
||||
const appendToMpvLog = createAppendToMpvLogHandler({
|
||||
logPath: '/tmp/subminer/mpv.log',
|
||||
dirname: (targetPath) => {
|
||||
calls.push(`dirname:${targetPath}`);
|
||||
return '/tmp/subminer';
|
||||
},
|
||||
mkdirSync: (targetPath) => {
|
||||
calls.push(`mkdir:${targetPath}`);
|
||||
},
|
||||
appendFileSync: (_targetPath, data) => {
|
||||
calls.push(`append:${data.trimEnd()}`);
|
||||
},
|
||||
now: () => new Date('2026-02-20T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
appendToMpvLog('hello');
|
||||
assert.deepEqual(calls, [
|
||||
'dirname:/tmp/subminer/mpv.log',
|
||||
'mkdir:/tmp/subminer',
|
||||
'append:[2026-02-20T00:00:00.000Z] hello',
|
||||
]);
|
||||
});
|
||||
|
||||
test('append mpv log swallows filesystem errors', () => {
|
||||
const appendToMpvLog = createAppendToMpvLogHandler({
|
||||
logPath: '/tmp/subminer/mpv.log',
|
||||
dirname: () => '/tmp/subminer',
|
||||
mkdirSync: () => {
|
||||
throw new Error('disk error');
|
||||
},
|
||||
appendFileSync: () => {
|
||||
throw new Error('should not reach');
|
||||
},
|
||||
now: () => new Date('2026-02-20T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => appendToMpvLog('hello'));
|
||||
});
|
||||
|
||||
test('show mpv osd logs marker and forwards fallback logging', () => {
|
||||
const calls: string[] = [];
|
||||
const client = { connected: false, send: () => {} } as never;
|
||||
const showMpvOsd = createShowMpvOsdHandler({
|
||||
appendToMpvLog: (message) => calls.push(`append:${message}`),
|
||||
showMpvOsdRuntime: (_client, text, fallbackLog) => {
|
||||
calls.push(`show:${text}`);
|
||||
fallbackLog('fallback-line');
|
||||
},
|
||||
getMpvClient: () => client,
|
||||
logInfo: (line) => calls.push(`info:${line}`),
|
||||
});
|
||||
|
||||
showMpvOsd('subtitle copied');
|
||||
assert.deepEqual(calls, [
|
||||
'append:[OSD] subtitle copied',
|
||||
'show:subtitle copied',
|
||||
'info:fallback-line',
|
||||
]);
|
||||
});
|
||||
42
src/main/runtime/mpv-osd-log.ts
Normal file
42
src/main/runtime/mpv-osd-log.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
|
||||
|
||||
export function createAppendToMpvLogHandler(deps: {
|
||||
logPath: string;
|
||||
dirname: (targetPath: string) => string;
|
||||
mkdirSync: (targetPath: string, options: { recursive: boolean }) => void;
|
||||
appendFileSync: (
|
||||
targetPath: string,
|
||||
data: string,
|
||||
options: { encoding: 'utf8' },
|
||||
) => void;
|
||||
now: () => Date;
|
||||
}) {
|
||||
return (message: string): void => {
|
||||
try {
|
||||
deps.mkdirSync(deps.dirname(deps.logPath), { recursive: true });
|
||||
deps.appendFileSync(deps.logPath, `[${deps.now().toISOString()}] ${message}\n`, {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
} catch {
|
||||
// best-effort logging
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createShowMpvOsdHandler(deps: {
|
||||
appendToMpvLog: (message: string) => void;
|
||||
showMpvOsdRuntime: (
|
||||
mpvClient: MpvRuntimeClientLike | null,
|
||||
text: string,
|
||||
fallbackLog: (line: string) => void,
|
||||
) => void;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
logInfo: (line: string) => void;
|
||||
}) {
|
||||
return (text: string): void => {
|
||||
deps.appendToMpvLog(`[OSD] ${text}`);
|
||||
deps.showMpvOsdRuntime(deps.getMpvClient(), text, (line) => {
|
||||
deps.logInfo(line);
|
||||
});
|
||||
};
|
||||
}
|
||||
63
src/main/runtime/mpv-subtitle-render-metrics.test.ts
Normal file
63
src/main/runtime/mpv-subtitle-render-metrics.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createUpdateMpvSubtitleRenderMetricsHandler } from './mpv-subtitle-render-metrics';
|
||||
import type { MpvSubtitleRenderMetrics } from '../../types';
|
||||
|
||||
const BASE_METRICS: MpvSubtitleRenderMetrics = {
|
||||
subPos: 100,
|
||||
subFontSize: 36,
|
||||
subScale: 1,
|
||||
subMarginY: 0,
|
||||
subMarginX: 0,
|
||||
subFont: '',
|
||||
subSpacing: 0,
|
||||
subBold: false,
|
||||
subItalic: false,
|
||||
subBorderSize: 0,
|
||||
subShadowOffset: 0,
|
||||
subAssOverride: 'yes',
|
||||
subScaleByWindow: true,
|
||||
subUseMargins: true,
|
||||
osdHeight: 0,
|
||||
osdDimensions: null,
|
||||
};
|
||||
|
||||
test('subtitle render metrics handler no-ops when patch does not change state', () => {
|
||||
let metrics = { ...BASE_METRICS };
|
||||
let broadcasts = 0;
|
||||
const updateMetrics = createUpdateMpvSubtitleRenderMetricsHandler({
|
||||
getCurrentMetrics: () => metrics,
|
||||
setCurrentMetrics: (next) => {
|
||||
metrics = next;
|
||||
},
|
||||
applyPatch: (current) => ({ next: current, changed: false }),
|
||||
broadcastMetrics: () => {
|
||||
broadcasts += 1;
|
||||
},
|
||||
});
|
||||
|
||||
updateMetrics({});
|
||||
assert.equal(broadcasts, 0);
|
||||
});
|
||||
|
||||
test('subtitle render metrics handler updates and broadcasts when changed', () => {
|
||||
let metrics = { ...BASE_METRICS };
|
||||
let broadcasts = 0;
|
||||
const updateMetrics = createUpdateMpvSubtitleRenderMetricsHandler({
|
||||
getCurrentMetrics: () => metrics,
|
||||
setCurrentMetrics: (next) => {
|
||||
metrics = next;
|
||||
},
|
||||
applyPatch: (current, patch) => ({
|
||||
next: { ...current, ...patch },
|
||||
changed: true,
|
||||
}),
|
||||
broadcastMetrics: () => {
|
||||
broadcasts += 1;
|
||||
},
|
||||
});
|
||||
|
||||
updateMetrics({ subPos: 80 });
|
||||
assert.equal(metrics.subPos, 80);
|
||||
assert.equal(broadcasts, 1);
|
||||
});
|
||||
18
src/main/runtime/mpv-subtitle-render-metrics.ts
Normal file
18
src/main/runtime/mpv-subtitle-render-metrics.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { MpvSubtitleRenderMetrics } from '../../types';
|
||||
|
||||
export function createUpdateMpvSubtitleRenderMetricsHandler(deps: {
|
||||
getCurrentMetrics: () => MpvSubtitleRenderMetrics;
|
||||
setCurrentMetrics: (metrics: MpvSubtitleRenderMetrics) => void;
|
||||
applyPatch: (
|
||||
current: MpvSubtitleRenderMetrics,
|
||||
patch: Partial<MpvSubtitleRenderMetrics>,
|
||||
) => { next: MpvSubtitleRenderMetrics; changed: boolean };
|
||||
broadcastMetrics: (metrics: MpvSubtitleRenderMetrics) => void;
|
||||
}) {
|
||||
return (patch: Partial<MpvSubtitleRenderMetrics>): void => {
|
||||
const { next, changed } = deps.applyPatch(deps.getCurrentMetrics(), patch);
|
||||
if (!changed) return;
|
||||
deps.setCurrentMetrics(next);
|
||||
deps.broadcastMetrics(next);
|
||||
};
|
||||
}
|
||||
42
src/main/runtime/numeric-shortcut-session-handlers.test.ts
Normal file
42
src/main/runtime/numeric-shortcut-session-handlers.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCancelNumericShortcutSessionHandler,
|
||||
createStartNumericShortcutSessionHandler,
|
||||
} from './numeric-shortcut-session-handlers';
|
||||
|
||||
test('cancel numeric shortcut session handler cancels active session', () => {
|
||||
const calls: string[] = [];
|
||||
const cancel = createCancelNumericShortcutSessionHandler({
|
||||
session: {
|
||||
start: () => {},
|
||||
cancel: () => calls.push('cancel'),
|
||||
},
|
||||
});
|
||||
|
||||
cancel();
|
||||
assert.deepEqual(calls, ['cancel']);
|
||||
});
|
||||
|
||||
test('start numeric shortcut session handler forwards timeout, messages, and onDigit', () => {
|
||||
const calls: string[] = [];
|
||||
const start = createStartNumericShortcutSessionHandler({
|
||||
session: {
|
||||
cancel: () => {},
|
||||
start: ({ timeoutMs, onDigit, messages }) => {
|
||||
calls.push(`timeout:${timeoutMs}`);
|
||||
calls.push(`prompt:${messages.prompt}`);
|
||||
onDigit(3);
|
||||
},
|
||||
},
|
||||
onDigit: (digit) => calls.push(`digit:${digit}`),
|
||||
messages: {
|
||||
prompt: 'Prompt',
|
||||
timeout: 'Timeout',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
});
|
||||
|
||||
start(1200);
|
||||
assert.deepEqual(calls, ['timeout:1200', 'prompt:Prompt', 'digit:3']);
|
||||
});
|
||||
31
src/main/runtime/numeric-shortcut-session-handlers.ts
Normal file
31
src/main/runtime/numeric-shortcut-session-handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
NumericShortcutSessionMessages,
|
||||
NumericShortcutSessionStartParams,
|
||||
} from '../../core/services/numeric-shortcut';
|
||||
|
||||
type NumericShortcutSessionLike = {
|
||||
start: (params: NumericShortcutSessionStartParams) => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export function createCancelNumericShortcutSessionHandler(deps: {
|
||||
session: NumericShortcutSessionLike;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.session.cancel();
|
||||
};
|
||||
}
|
||||
|
||||
export function createStartNumericShortcutSessionHandler(deps: {
|
||||
session: NumericShortcutSessionLike;
|
||||
onDigit: (digit: number) => void;
|
||||
messages: NumericShortcutSessionMessages;
|
||||
}) {
|
||||
return (timeoutMs: number): void => {
|
||||
deps.session.start({
|
||||
timeoutMs,
|
||||
onDigit: deps.onDigit,
|
||||
messages: deps.messages,
|
||||
});
|
||||
};
|
||||
}
|
||||
51
src/main/runtime/overlay-runtime-bootstrap.test.ts
Normal file
51
src/main/runtime/overlay-runtime-bootstrap.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInitializeOverlayRuntimeHandler } from './overlay-runtime-bootstrap';
|
||||
|
||||
test('overlay runtime bootstrap no-ops when already initialized', () => {
|
||||
let coreCalls = 0;
|
||||
const initialize = createInitializeOverlayRuntimeHandler({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
initializeOverlayRuntimeCore: () => {
|
||||
coreCalls += 1;
|
||||
return { invisibleOverlayVisible: false };
|
||||
},
|
||||
buildOptions: () => ({} as never),
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
});
|
||||
|
||||
initialize();
|
||||
assert.equal(coreCalls, 0);
|
||||
});
|
||||
|
||||
test('overlay runtime bootstrap runs core init and applies post-init state', () => {
|
||||
const calls: string[] = [];
|
||||
let initialized = false;
|
||||
const initialize = createInitializeOverlayRuntimeHandler({
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
initializeOverlayRuntimeCore: () => {
|
||||
calls.push('core');
|
||||
return { invisibleOverlayVisible: true };
|
||||
},
|
||||
buildOptions: () => {
|
||||
calls.push('options');
|
||||
return {} as never;
|
||||
},
|
||||
setInvisibleOverlayVisible: (visible) => {
|
||||
calls.push(`invisible:${visible ? 'yes' : 'no'}`);
|
||||
},
|
||||
setOverlayRuntimeInitialized: (value) => {
|
||||
initialized = value;
|
||||
calls.push(`initialized:${value ? 'yes' : 'no'}`);
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('warmups');
|
||||
},
|
||||
});
|
||||
|
||||
initialize();
|
||||
assert.equal(initialized, true);
|
||||
assert.deepEqual(calls, ['options', 'core', 'invisible:yes', 'initialized:yes', 'warmups']);
|
||||
});
|
||||
55
src/main/runtime/overlay-runtime-bootstrap.ts
Normal file
55
src/main/runtime/overlay-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { BaseWindowTracker } from '../../window-trackers';
|
||||
import type {
|
||||
AnkiConnectConfig,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
WindowGeometry,
|
||||
} from '../../types';
|
||||
|
||||
type InitializeOverlayRuntimeCore = (options: {
|
||||
backendOverride: string | null;
|
||||
getInitialInvisibleOverlayVisibility: () => boolean;
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
isInvisibleOverlayVisible: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
getMpvSocketPath: () => string;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
||||
getRuntimeOptionsManager: () => {
|
||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
}) => { invisibleOverlayVisible: boolean };
|
||||
|
||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntimeCore: InitializeOverlayRuntimeCore;
|
||||
buildOptions: () => Parameters<InitializeOverlayRuntimeCore>[0];
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
setOverlayRuntimeInitialized: (initialized: boolean) => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
if (deps.isOverlayRuntimeInitialized()) return;
|
||||
const result = deps.initializeOverlayRuntimeCore(deps.buildOptions());
|
||||
deps.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
|
||||
deps.setOverlayRuntimeInitialized(true);
|
||||
deps.startBackgroundWarmups();
|
||||
};
|
||||
}
|
||||
49
src/main/runtime/overlay-shortcuts-lifecycle.test.ts
Normal file
49
src/main/runtime/overlay-shortcuts-lifecycle.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createRefreshOverlayShortcutsHandler,
|
||||
createRegisterOverlayShortcutsHandler,
|
||||
createSyncOverlayShortcutsHandler,
|
||||
createUnregisterOverlayShortcutsHandler,
|
||||
} from './overlay-shortcuts-lifecycle';
|
||||
|
||||
function createRuntime(calls: string[]) {
|
||||
return {
|
||||
registerOverlayShortcuts: () => calls.push('register'),
|
||||
unregisterOverlayShortcuts: () => calls.push('unregister'),
|
||||
syncOverlayShortcuts: () => calls.push('sync'),
|
||||
refreshOverlayShortcuts: () => calls.push('refresh'),
|
||||
};
|
||||
}
|
||||
|
||||
test('register overlay shortcuts handler delegates to runtime', () => {
|
||||
const calls: string[] = [];
|
||||
createRegisterOverlayShortcutsHandler({
|
||||
overlayShortcutsRuntime: createRuntime(calls),
|
||||
})();
|
||||
assert.deepEqual(calls, ['register']);
|
||||
});
|
||||
|
||||
test('unregister overlay shortcuts handler delegates to runtime', () => {
|
||||
const calls: string[] = [];
|
||||
createUnregisterOverlayShortcutsHandler({
|
||||
overlayShortcutsRuntime: createRuntime(calls),
|
||||
})();
|
||||
assert.deepEqual(calls, ['unregister']);
|
||||
});
|
||||
|
||||
test('sync overlay shortcuts handler delegates to runtime', () => {
|
||||
const calls: string[] = [];
|
||||
createSyncOverlayShortcutsHandler({
|
||||
overlayShortcutsRuntime: createRuntime(calls),
|
||||
})();
|
||||
assert.deepEqual(calls, ['sync']);
|
||||
});
|
||||
|
||||
test('refresh overlay shortcuts handler delegates to runtime', () => {
|
||||
const calls: string[] = [];
|
||||
createRefreshOverlayShortcutsHandler({
|
||||
overlayShortcutsRuntime: createRuntime(calls),
|
||||
})();
|
||||
assert.deepEqual(calls, ['refresh']);
|
||||
});
|
||||
38
src/main/runtime/overlay-shortcuts-lifecycle.ts
Normal file
38
src/main/runtime/overlay-shortcuts-lifecycle.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
type OverlayShortcutsRuntimeLike = {
|
||||
registerOverlayShortcuts: () => void;
|
||||
unregisterOverlayShortcuts: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
refreshOverlayShortcuts: () => void;
|
||||
};
|
||||
|
||||
export function createRegisterOverlayShortcutsHandler(deps: {
|
||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.overlayShortcutsRuntime.registerOverlayShortcuts();
|
||||
};
|
||||
}
|
||||
|
||||
export function createUnregisterOverlayShortcutsHandler(deps: {
|
||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.overlayShortcutsRuntime.unregisterOverlayShortcuts();
|
||||
};
|
||||
}
|
||||
|
||||
export function createSyncOverlayShortcutsHandler(deps: {
|
||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.overlayShortcutsRuntime.syncOverlayShortcuts();
|
||||
};
|
||||
}
|
||||
|
||||
export function createRefreshOverlayShortcutsHandler(deps: {
|
||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.overlayShortcutsRuntime.refreshOverlayShortcuts();
|
||||
};
|
||||
}
|
||||
53
src/main/runtime/overlay-window-layout.test.ts
Normal file
53
src/main/runtime/overlay-window-layout.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createEnforceOverlayLayerOrderHandler,
|
||||
createEnsureOverlayWindowLevelHandler,
|
||||
createUpdateInvisibleOverlayBoundsHandler,
|
||||
createUpdateVisibleOverlayBoundsHandler,
|
||||
} from './overlay-window-layout';
|
||||
|
||||
test('visible bounds handler writes visible layer geometry', () => {
|
||||
const calls: string[] = [];
|
||||
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
|
||||
setOverlayWindowBounds: (layer) => calls.push(layer),
|
||||
});
|
||||
handleVisible({ x: 0, y: 0, width: 100, height: 50 });
|
||||
assert.deepEqual(calls, ['visible']);
|
||||
});
|
||||
|
||||
test('invisible bounds handler writes invisible layer geometry', () => {
|
||||
const calls: string[] = [];
|
||||
const handleInvisible = createUpdateInvisibleOverlayBoundsHandler({
|
||||
setOverlayWindowBounds: (layer) => calls.push(layer),
|
||||
});
|
||||
handleInvisible({ x: 0, y: 0, width: 100, height: 50 });
|
||||
assert.deepEqual(calls, ['invisible']);
|
||||
});
|
||||
|
||||
test('ensure overlay window level handler delegates to core', () => {
|
||||
const calls: string[] = [];
|
||||
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
||||
ensureOverlayWindowLevelCore: () => calls.push('core'),
|
||||
});
|
||||
ensureLevel({});
|
||||
assert.deepEqual(calls, ['core']);
|
||||
});
|
||||
|
||||
test('enforce overlay layer order handler forwards resolved state', () => {
|
||||
const calls: string[] = [];
|
||||
const enforce = createEnforceOverlayLayerOrderHandler({
|
||||
enforceOverlayLayerOrderCore: (params) => {
|
||||
calls.push(params.visibleOverlayVisible ? 'visible-on' : 'visible-off');
|
||||
calls.push(params.invisibleOverlayVisible ? 'invisible-on' : 'invisible-off');
|
||||
params.ensureOverlayWindowLevel({});
|
||||
},
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
getMainWindow: () => ({}),
|
||||
getInvisibleWindow: () => ({}),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
||||
});
|
||||
enforce();
|
||||
assert.deepEqual(calls, ['visible-on', 'invisible-off', 'ensure-level']);
|
||||
});
|
||||
50
src/main/runtime/overlay-window-layout.ts
Normal file
50
src/main/runtime/overlay-window-layout.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { WindowGeometry } from '../../types';
|
||||
|
||||
export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
||||
setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void;
|
||||
}) {
|
||||
return (geometry: WindowGeometry): void => {
|
||||
deps.setOverlayWindowBounds('visible', geometry);
|
||||
};
|
||||
}
|
||||
|
||||
export function createUpdateInvisibleOverlayBoundsHandler(deps: {
|
||||
setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void;
|
||||
}) {
|
||||
return (geometry: WindowGeometry): void => {
|
||||
deps.setOverlayWindowBounds('invisible', geometry);
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnsureOverlayWindowLevelHandler(deps: {
|
||||
ensureOverlayWindowLevelCore: (window: unknown) => void;
|
||||
}) {
|
||||
return (window: unknown): void => {
|
||||
deps.ensureOverlayWindowLevelCore(window);
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnforceOverlayLayerOrderHandler(deps: {
|
||||
enforceOverlayLayerOrderCore: (params: {
|
||||
visibleOverlayVisible: boolean;
|
||||
invisibleOverlayVisible: boolean;
|
||||
mainWindow: unknown;
|
||||
invisibleWindow: unknown;
|
||||
ensureOverlayWindowLevel: (window: unknown) => void;
|
||||
}) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => unknown;
|
||||
getInvisibleWindow: () => unknown;
|
||||
ensureOverlayWindowLevel: (window: unknown) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.enforceOverlayLayerOrderCore({
|
||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
||||
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
|
||||
mainWindow: deps.getMainWindow(),
|
||||
invisibleWindow: deps.getInvisibleWindow(),
|
||||
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
|
||||
});
|
||||
};
|
||||
}
|
||||
71
src/main/runtime/startup-warmups.test.ts
Normal file
71
src/main/runtime/startup-warmups.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createLaunchBackgroundWarmupTaskHandler,
|
||||
createStartBackgroundWarmupsHandler,
|
||||
} from './startup-warmups';
|
||||
|
||||
test('launchBackgroundWarmupTask logs completion timing', async () => {
|
||||
const debugLogs: string[] = [];
|
||||
const launchTask = createLaunchBackgroundWarmupTaskHandler({
|
||||
now: (() => {
|
||||
let tick = 0;
|
||||
return () => ++tick * 10;
|
||||
})(),
|
||||
logDebug: (message) => debugLogs.push(message),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
launchTask('demo', async () => {});
|
||||
await Promise.resolve();
|
||||
assert.ok(debugLogs.some((line) => line.includes('[startup-warmup] demo completed in')));
|
||||
});
|
||||
|
||||
test('startBackgroundWarmups no-ops when already started', () => {
|
||||
let launches = 0;
|
||||
const startWarmups = createStartBackgroundWarmupsHandler({
|
||||
getStarted: () => true,
|
||||
setStarted: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
launchTask: () => {
|
||||
launches += 1;
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
ensureYomitanExtensionLoaded: async () => {},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
shouldAutoConnectJellyfinRemote: () => false,
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
});
|
||||
|
||||
startWarmups();
|
||||
assert.equal(launches, 0);
|
||||
});
|
||||
|
||||
test('startBackgroundWarmups schedules base warmups and optional jellyfin warmup', () => {
|
||||
const labels: string[] = [];
|
||||
let started = false;
|
||||
const startWarmups = createStartBackgroundWarmupsHandler({
|
||||
getStarted: () => started,
|
||||
setStarted: (value) => {
|
||||
started = value;
|
||||
},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
launchTask: (label) => {
|
||||
labels.push(label);
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
ensureYomitanExtensionLoaded: async () => {},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
shouldAutoConnectJellyfinRemote: () => true,
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
});
|
||||
|
||||
startWarmups();
|
||||
assert.equal(started, true);
|
||||
assert.deepEqual(labels, [
|
||||
'mecab',
|
||||
'yomitan-extension',
|
||||
'subtitle-dictionaries',
|
||||
'jellyfin-remote-session',
|
||||
]);
|
||||
});
|
||||
50
src/main/runtime/startup-warmups.ts
Normal file
50
src/main/runtime/startup-warmups.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export function createLaunchBackgroundWarmupTaskHandler(deps: {
|
||||
now: () => number;
|
||||
logDebug: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
}) {
|
||||
return (label: string, task: () => Promise<void>): void => {
|
||||
const startedAtMs = deps.now();
|
||||
void task()
|
||||
.then(() => {
|
||||
deps.logDebug(`[startup-warmup] ${label} completed in ${deps.now() - startedAtMs}ms`);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.logWarn(`[startup-warmup] ${label} failed: ${message}`);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createStartBackgroundWarmupsHandler(deps: {
|
||||
getStarted: () => boolean;
|
||||
setStarted: (started: boolean) => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
launchTask: (label: string, task: () => Promise<void>) => void;
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries: () => Promise<void>;
|
||||
shouldAutoConnectJellyfinRemote: () => boolean;
|
||||
startJellyfinRemoteSession: () => Promise<void>;
|
||||
}) {
|
||||
return (): void => {
|
||||
if (deps.getStarted()) return;
|
||||
if (deps.isTexthookerOnlyMode()) return;
|
||||
|
||||
deps.setStarted(true);
|
||||
deps.launchTask('mecab', async () => {
|
||||
await deps.createMecabTokenizerAndCheck();
|
||||
});
|
||||
deps.launchTask('yomitan-extension', async () => {
|
||||
await deps.ensureYomitanExtensionLoaded();
|
||||
});
|
||||
deps.launchTask('subtitle-dictionaries', async () => {
|
||||
await deps.prewarmSubtitleDictionaries();
|
||||
});
|
||||
if (deps.shouldAutoConnectJellyfinRemote()) {
|
||||
deps.launchTask('jellyfin-remote-session', async () => {
|
||||
await deps.startJellyfinRemoteSession();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
119
src/main/runtime/tray-lifecycle.test.ts
Normal file
119
src/main/runtime/tray-lifecycle.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle';
|
||||
|
||||
test('ensure tray updates menu when tray already exists', () => {
|
||||
const calls: string[] = [];
|
||||
const tray = {
|
||||
setContextMenu: () => calls.push('set-menu'),
|
||||
setToolTip: () => calls.push('set-tooltip'),
|
||||
on: () => calls.push('bind-click'),
|
||||
destroy: () => calls.push('destroy'),
|
||||
};
|
||||
|
||||
const ensureTray = createEnsureTrayHandler({
|
||||
getTray: () => tray,
|
||||
setTray: () => calls.push('set-tray'),
|
||||
buildTrayMenu: () => ({}),
|
||||
resolveTrayIconPath: () => null,
|
||||
createImageFromPath: () =>
|
||||
({
|
||||
isEmpty: () => false,
|
||||
resize: () => {
|
||||
throw new Error('should not resize');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createEmptyImage: () =>
|
||||
({
|
||||
isEmpty: () => true,
|
||||
resize: () => {
|
||||
throw new Error('should not resize');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createTray: () => tray as never,
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: 'darwin',
|
||||
logWarn: () => calls.push('warn'),
|
||||
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
|
||||
});
|
||||
|
||||
ensureTray();
|
||||
assert.deepEqual(calls, ['set-menu']);
|
||||
});
|
||||
|
||||
test('ensure tray creates new tray and binds click handler', () => {
|
||||
const calls: string[] = [];
|
||||
let trayRef: unknown = null;
|
||||
|
||||
const ensureTray = createEnsureTrayHandler({
|
||||
getTray: () => null,
|
||||
setTray: (tray) => {
|
||||
trayRef = tray;
|
||||
calls.push('set-tray');
|
||||
},
|
||||
buildTrayMenu: () => ({ id: 'menu' }),
|
||||
resolveTrayIconPath: () => '/tmp/icon.png',
|
||||
createImageFromPath: () =>
|
||||
({
|
||||
isEmpty: () => false,
|
||||
resize: (options: { width: number; height: number; quality?: 'best' | 'better' | 'good' }) => {
|
||||
calls.push(`resize:${options.width}x${options.height}`);
|
||||
return {
|
||||
isEmpty: () => false,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => calls.push('template'),
|
||||
};
|
||||
},
|
||||
setTemplateImage: () => calls.push('template'),
|
||||
}) as never,
|
||||
createEmptyImage: () =>
|
||||
({
|
||||
isEmpty: () => true,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createTray: () =>
|
||||
({
|
||||
setContextMenu: () => calls.push('set-menu'),
|
||||
setToolTip: () => calls.push('set-tooltip'),
|
||||
on: (_event: 'click', _handler: () => void) => {
|
||||
calls.push('bind-click');
|
||||
},
|
||||
destroy: () => calls.push('destroy'),
|
||||
}) as never,
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: 'darwin',
|
||||
logWarn: () => calls.push('warn'),
|
||||
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
|
||||
});
|
||||
|
||||
ensureTray();
|
||||
assert.ok(trayRef);
|
||||
assert.ok(calls.includes('set-tray'));
|
||||
assert.ok(calls.includes('set-menu'));
|
||||
assert.ok(calls.includes('bind-click'));
|
||||
});
|
||||
|
||||
test('destroy tray handler destroys active tray and clears ref', () => {
|
||||
const calls: string[] = [];
|
||||
let tray: { destroy: () => void } | null = {
|
||||
destroy: () => calls.push('destroy'),
|
||||
};
|
||||
const destroyTray = createDestroyTrayHandler({
|
||||
getTray: () => tray as never,
|
||||
setTray: (next) => {
|
||||
tray = next as never;
|
||||
calls.push('set-null');
|
||||
},
|
||||
});
|
||||
|
||||
destroyTray();
|
||||
assert.deepEqual(calls, ['destroy', 'set-null']);
|
||||
assert.equal(tray, null);
|
||||
});
|
||||
67
src/main/runtime/tray-lifecycle.ts
Normal file
67
src/main/runtime/tray-lifecycle.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
type TrayIconLike = {
|
||||
isEmpty: () => boolean;
|
||||
resize: (options: { width: number; height: number; quality?: 'best' | 'better' | 'good' }) => TrayIconLike;
|
||||
setTemplateImage: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
type TrayLike = {
|
||||
setContextMenu: (menu: any) => void;
|
||||
setToolTip: (tooltip: string) => void;
|
||||
on: (event: 'click', handler: () => void) => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
export function createEnsureTrayHandler(deps: {
|
||||
getTray: () => TrayLike | null;
|
||||
setTray: (tray: TrayLike | null) => void;
|
||||
buildTrayMenu: () => any;
|
||||
resolveTrayIconPath: () => string | null;
|
||||
createImageFromPath: (iconPath: string) => TrayIconLike;
|
||||
createEmptyImage: () => TrayIconLike;
|
||||
createTray: (icon: TrayIconLike) => TrayLike;
|
||||
trayTooltip: string;
|
||||
platform: string;
|
||||
logWarn: (message: string) => void;
|
||||
ensureOverlayVisibleFromTrayClick: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
const existingTray = deps.getTray();
|
||||
if (existingTray) {
|
||||
existingTray.setContextMenu(deps.buildTrayMenu());
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPath = deps.resolveTrayIconPath();
|
||||
let trayIcon = iconPath ? deps.createImageFromPath(iconPath) : deps.createEmptyImage();
|
||||
if (trayIcon.isEmpty()) {
|
||||
deps.logWarn('Tray icon asset not found; using empty icon placeholder.');
|
||||
}
|
||||
if (deps.platform === 'darwin' && !trayIcon.isEmpty()) {
|
||||
trayIcon = trayIcon.resize({ width: 18, height: 18, quality: 'best' });
|
||||
trayIcon.setTemplateImage(true);
|
||||
}
|
||||
if (deps.platform === 'linux' && !trayIcon.isEmpty()) {
|
||||
trayIcon = trayIcon.resize({ width: 20, height: 20 });
|
||||
}
|
||||
|
||||
const tray = deps.createTray(trayIcon);
|
||||
tray.setToolTip(deps.trayTooltip);
|
||||
tray.setContextMenu(deps.buildTrayMenu());
|
||||
tray.on('click', () => {
|
||||
deps.ensureOverlayVisibleFromTrayClick();
|
||||
});
|
||||
deps.setTray(tray);
|
||||
};
|
||||
}
|
||||
|
||||
export function createDestroyTrayHandler(deps: {
|
||||
getTray: () => TrayLike | null;
|
||||
setTray: (tray: TrayLike | null) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
const tray = deps.getTray();
|
||||
if (!tray) return;
|
||||
tray.destroy();
|
||||
deps.setTray(null);
|
||||
};
|
||||
}
|
||||
45
src/main/runtime/tray-runtime.test.ts
Normal file
45
src/main/runtime/tray-runtime.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildTrayMenuTemplateRuntime, resolveTrayIconPathRuntime } from './tray-runtime';
|
||||
|
||||
test('resolve tray icon picks template icon first on darwin', () => {
|
||||
const path = resolveTrayIconPathRuntime({
|
||||
platform: 'darwin',
|
||||
resourcesPath: '/res',
|
||||
appPath: '/app',
|
||||
dirname: '/dist/main',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: (candidate) => candidate.endsWith('/res/assets/SubMinerTemplate.png'),
|
||||
});
|
||||
assert.equal(path, '/res/assets/SubMinerTemplate.png');
|
||||
});
|
||||
|
||||
test('resolve tray icon returns null when no asset exists', () => {
|
||||
const path = resolveTrayIconPathRuntime({
|
||||
platform: 'linux',
|
||||
resourcesPath: '/res',
|
||||
appPath: '/app',
|
||||
dirname: '/dist/main',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: () => false,
|
||||
});
|
||||
assert.equal(path, null);
|
||||
});
|
||||
|
||||
test('tray menu template contains expected entries and handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => calls.push('overlay'),
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptions: () => calls.push('runtime'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.equal(template.length, 7);
|
||||
template[0].click?.();
|
||||
template[5].type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[6].click?.();
|
||||
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
|
||||
});
|
||||
73
src/main/runtime/tray-runtime.ts
Normal file
73
src/main/runtime/tray-runtime.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export function resolveTrayIconPathRuntime(deps: {
|
||||
platform: string;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
dirname: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (path: string) => boolean;
|
||||
}): string | null {
|
||||
const iconNames =
|
||||
deps.platform === 'darwin'
|
||||
? ['SubMinerTemplate.png', 'SubMinerTemplate@2x.png', 'SubMiner.png']
|
||||
: ['SubMiner.png'];
|
||||
|
||||
const baseDirs = [
|
||||
deps.joinPath(deps.resourcesPath, 'assets'),
|
||||
deps.joinPath(deps.appPath, 'assets'),
|
||||
deps.joinPath(deps.dirname, '..', 'assets'),
|
||||
deps.joinPath(deps.dirname, '..', '..', 'assets'),
|
||||
];
|
||||
|
||||
for (const baseDir of baseDirs) {
|
||||
for (const iconName of iconNames) {
|
||||
const candidate = deps.joinPath(baseDir, iconName);
|
||||
if (deps.fileExists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type TrayMenuActionHandlers = {
|
||||
openOverlay: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
quitApp: () => void;
|
||||
};
|
||||
|
||||
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
|
||||
label?: string;
|
||||
type?: 'separator';
|
||||
click?: () => void;
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
label: 'Open Overlay',
|
||||
click: handlers.openOverlay,
|
||||
},
|
||||
{
|
||||
label: 'Open Yomitan Settings',
|
||||
click: handlers.openYomitanSettings,
|
||||
},
|
||||
{
|
||||
label: 'Open Runtime Options',
|
||||
click: handlers.openRuntimeOptions,
|
||||
},
|
||||
{
|
||||
label: 'Configure Jellyfin',
|
||||
click: handlers.openJellyfinSetup,
|
||||
},
|
||||
{
|
||||
label: 'Configure AniList',
|
||||
click: handlers.openAnilistSetup,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: handlers.quitApp,
|
||||
},
|
||||
];
|
||||
}
|
||||
41
src/main/runtime/yomitan-settings-opener.test.ts
Normal file
41
src/main/runtime/yomitan-settings-opener.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOpenYomitanSettingsHandler } from './yomitan-settings-opener';
|
||||
|
||||
test('yomitan opener warns when extension cannot be loaded', async () => {
|
||||
const logs: string[] = [];
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => null,
|
||||
openYomitanSettingsWindow: () => {
|
||||
throw new Error('should not open');
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
logWarn: (message) => logs.push(message),
|
||||
logError: () => logs.push('error'),
|
||||
});
|
||||
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.ok(logs.includes('Unable to open Yomitan settings: extension failed to load.'));
|
||||
});
|
||||
|
||||
test('yomitan opener opens settings window when extension is available', async () => {
|
||||
let opened = false;
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: () => {
|
||||
opened = true;
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(opened, true);
|
||||
});
|
||||
32
src/main/runtime/yomitan-settings-opener.ts
Normal file
32
src/main/runtime/yomitan-settings-opener.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
type YomitanExtensionLike = unknown;
|
||||
type BrowserWindowLike = unknown;
|
||||
|
||||
export function createOpenYomitanSettingsHandler(deps: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
|
||||
openYomitanSettingsWindow: (params: {
|
||||
yomitanExt: YomitanExtensionLike;
|
||||
getExistingWindow: () => BrowserWindowLike | null;
|
||||
setWindow: (window: BrowserWindowLike | null) => void;
|
||||
}) => void;
|
||||
getExistingWindow: () => BrowserWindowLike | null;
|
||||
setWindow: (window: BrowserWindowLike | null) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
void (async () => {
|
||||
const extension = await deps.ensureYomitanExtensionLoaded();
|
||||
if (!extension) {
|
||||
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
|
||||
return;
|
||||
}
|
||||
deps.openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: deps.getExistingWindow,
|
||||
setWindow: deps.setWindow,
|
||||
});
|
||||
})().catch((error) => {
|
||||
deps.logError('Failed to open Yomitan settings window.', error);
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user