From 9d6b2f840cd7a424f6e2ce30f01f4748edd7f388 Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:13:47 -0800 Subject: [PATCH] refactor(core): consolidate services and remove runtime wrappers --- ...time-service.ts => anki-jimaku-service.ts} | 0 ...app-lifecycle-deps-runtime-service.test.ts | 79 ------ .../app-lifecycle-deps-runtime-service.ts | 57 ---- src/core/services/app-lifecycle-service.ts | 63 ++++- .../app-logging-runtime-service.test.ts | 28 -- .../services/app-logging-runtime-service.ts | 28 -- ...vice.test.ts => app-ready-service.test.ts} | 2 +- .../app-shutdown-runtime-service.test.ts | 32 --- .../services/app-shutdown-runtime-service.ts | 27 -- .../cli-command-deps-runtime-service.test.ts | 110 -------- .../cli-command-deps-runtime-service.ts | 132 ---------- src/core/services/cli-command-service.ts | 131 ++++++++++ .../config-generation-runtime-service.test.ts | 67 ----- .../config-generation-runtime-service.ts | 26 -- .../config-warning-runtime-service.test.ts | 34 --- .../config-warning-runtime-service.ts | 14 - ...=> field-grouping-overlay-service.test.ts} | 2 +- ...e.ts => field-grouping-overlay-service.ts} | 2 +- src/core/services/field-grouping-service.ts | 14 +- src/core/services/index.ts | 56 ++-- .../services/ipc-deps-runtime-service.test.ts | 108 -------- src/core/services/ipc-deps-runtime-service.ts | 100 ------- src/core/services/ipc-service.ts | 101 ++++++- ...u-runtime-service.ts => jimaku-service.ts} | 0 ...g-runtime-service.ts => mining-service.ts} | 0 ...ce.test.ts => mpv-control-service.test.ts} | 2 +- ...time-service.ts => mpv-control-service.ts} | 0 .../numeric-shortcut-runtime-service.test.ts | 48 ---- .../numeric-shortcut-runtime-service.ts | 37 --- ...service.ts => numeric-shortcut-service.ts} | 34 +++ .../numeric-shortcut-session-service.test.ts | 50 +++- ...test.ts => overlay-bridge-service.test.ts} | 2 +- ...e-service.ts => overlay-bridge-service.ts} | 29 +-- .../overlay-broadcast-runtime-service.test.ts | 58 ----- .../overlay-broadcast-runtime-service.ts | 45 ---- .../services/overlay-manager-service.test.ts | 58 ++++- src/core/services/overlay-manager-service.ts | 33 +++ .../overlay-modal-restore-service.test.ts | 30 --- .../services/overlay-modal-restore-service.ts | 18 -- src/core/services/overlay-send-service.ts | 26 -- .../overlay-shortcut-fallback-runner.ts | 114 -------- ...service.ts => overlay-shortcut-handler.ts} | 117 ++++++++- .../overlay-shortcut-lifecycle-service.ts | 52 ---- src/core/services/overlay-shortcut-service.ts | 50 ++++ .../overlay-visibility-runtime-service.ts | 46 ---- .../services/overlay-visibility-service.ts | 63 +++-- ...ts => runtime-options-ipc-service.test.ts} | 2 +- ...vice.ts => runtime-options-ipc-service.ts} | 0 ...me-options-manager-runtime-service.test.ts | 25 -- ...runtime-options-manager-runtime-service.ts | 17 -- .../shortcut-ui-deps-runtime-service.test.ts | 62 ----- .../shortcut-ui-deps-runtime-service.ts | 24 -- .../startup-bootstrap-runtime-service.ts | 53 ---- ...t.ts => startup-bootstrap-service.test.ts} | 2 +- .../startup-resource-runtime-service.test.ts | 36 --- .../startup-resource-runtime-service.ts | 26 -- ...-runtime-service.ts => startup-service.ts} | 58 ++++- ...e-service.ts => subsync-runner-service.ts} | 0 .../tokenizer-deps-runtime-service.test.ts | 48 ---- .../tokenizer-deps-runtime-service.ts | 45 ---- src/core/services/tokenizer-service.ts | 43 ++- src/main.ts | 246 +++++++++--------- 62 files changed, 954 insertions(+), 1858 deletions(-) rename src/core/services/{anki-jimaku-runtime-service.ts => anki-jimaku-service.ts} (100%) delete mode 100644 src/core/services/app-lifecycle-deps-runtime-service.test.ts delete mode 100644 src/core/services/app-lifecycle-deps-runtime-service.ts delete mode 100644 src/core/services/app-logging-runtime-service.test.ts delete mode 100644 src/core/services/app-logging-runtime-service.ts rename src/core/services/{app-ready-runtime-service.test.ts => app-ready-service.test.ts} (98%) delete mode 100644 src/core/services/app-shutdown-runtime-service.test.ts delete mode 100644 src/core/services/app-shutdown-runtime-service.ts delete mode 100644 src/core/services/cli-command-deps-runtime-service.test.ts delete mode 100644 src/core/services/cli-command-deps-runtime-service.ts delete mode 100644 src/core/services/config-generation-runtime-service.test.ts delete mode 100644 src/core/services/config-generation-runtime-service.ts delete mode 100644 src/core/services/config-warning-runtime-service.test.ts delete mode 100644 src/core/services/config-warning-runtime-service.ts rename src/core/services/{field-grouping-overlay-runtime-service.test.ts => field-grouping-overlay-service.test.ts} (98%) rename src/core/services/{field-grouping-overlay-runtime-service.ts => field-grouping-overlay-service.ts} (98%) delete mode 100644 src/core/services/ipc-deps-runtime-service.test.ts delete mode 100644 src/core/services/ipc-deps-runtime-service.ts rename src/core/services/{jimaku-runtime-service.ts => jimaku-service.ts} (100%) rename src/core/services/{mining-runtime-service.ts => mining-service.ts} (100%) rename src/core/services/{mpv-runtime-service.test.ts => mpv-control-service.test.ts} (98%) rename src/core/services/{mpv-runtime-service.ts => mpv-control-service.ts} (100%) delete mode 100644 src/core/services/numeric-shortcut-runtime-service.test.ts delete mode 100644 src/core/services/numeric-shortcut-runtime-service.ts rename src/core/services/{numeric-shortcut-session-service.ts => numeric-shortcut-service.ts} (70%) rename src/core/services/{overlay-bridge-runtime-service.test.ts => overlay-bridge-service.test.ts} (98%) rename src/core/services/{overlay-bridge-runtime-service.ts => overlay-bridge-service.ts} (73%) delete mode 100644 src/core/services/overlay-broadcast-runtime-service.test.ts delete mode 100644 src/core/services/overlay-broadcast-runtime-service.ts delete mode 100644 src/core/services/overlay-modal-restore-service.test.ts delete mode 100644 src/core/services/overlay-modal-restore-service.ts delete mode 100644 src/core/services/overlay-send-service.ts delete mode 100644 src/core/services/overlay-shortcut-fallback-runner.ts rename src/core/services/{overlay-shortcut-runtime-service.ts => overlay-shortcut-handler.ts} (53%) delete mode 100644 src/core/services/overlay-shortcut-lifecycle-service.ts delete mode 100644 src/core/services/overlay-visibility-runtime-service.ts rename src/core/services/{runtime-options-runtime-service.test.ts => runtime-options-ipc-service.test.ts} (97%) rename src/core/services/{runtime-options-runtime-service.ts => runtime-options-ipc-service.ts} (100%) delete mode 100644 src/core/services/runtime-options-manager-runtime-service.test.ts delete mode 100644 src/core/services/runtime-options-manager-runtime-service.ts delete mode 100644 src/core/services/shortcut-ui-deps-runtime-service.test.ts delete mode 100644 src/core/services/shortcut-ui-deps-runtime-service.ts delete mode 100644 src/core/services/startup-bootstrap-runtime-service.ts rename src/core/services/{startup-bootstrap-runtime-service.test.ts => startup-bootstrap-service.test.ts} (98%) delete mode 100644 src/core/services/startup-resource-runtime-service.test.ts delete mode 100644 src/core/services/startup-resource-runtime-service.ts rename src/core/services/{app-ready-runtime-service.ts => startup-service.ts} (58%) rename src/core/services/{subsync-runtime-service.ts => subsync-runner-service.ts} (100%) delete mode 100644 src/core/services/tokenizer-deps-runtime-service.test.ts delete mode 100644 src/core/services/tokenizer-deps-runtime-service.ts diff --git a/src/core/services/anki-jimaku-runtime-service.ts b/src/core/services/anki-jimaku-service.ts similarity index 100% rename from src/core/services/anki-jimaku-runtime-service.ts rename to src/core/services/anki-jimaku-service.ts diff --git a/src/core/services/app-lifecycle-deps-runtime-service.test.ts b/src/core/services/app-lifecycle-deps-runtime-service.test.ts deleted file mode 100644 index 7eb8075..0000000 --- a/src/core/services/app-lifecycle-deps-runtime-service.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createAppLifecycleDepsRuntimeService } from "./app-lifecycle-deps-runtime-service"; - -test("createAppLifecycleDepsRuntimeService maps app methods and platform", async () => { - const events: Record void> = {}; - let lockRequested = 0; - let quitCalled = 0; - const deps = createAppLifecycleDepsRuntimeService({ - app: { - requestSingleInstanceLock: () => { - lockRequested += 1; - return true; - }, - quit: () => { - quitCalled += 1; - }, - on: (event, listener) => { - events[event] = listener; - }, - whenReady: async () => {}, - }, - platform: "darwin", - shouldStartApp: () => true, - parseArgs: () => ({ - start: false, - stop: false, - toggle: false, - toggleVisibleOverlay: false, - toggleInvisibleOverlay: false, - settings: false, - show: false, - hide: false, - showVisibleOverlay: false, - hideVisibleOverlay: false, - showInvisibleOverlay: false, - hideInvisibleOverlay: false, - copySubtitle: false, - copySubtitleMultiple: false, - mineSentence: false, - mineSentenceMultiple: false, - updateLastCardFromClipboard: false, - toggleSecondarySub: false, - triggerFieldGrouping: false, - triggerSubsync: false, - markAudioCard: false, - openRuntimeOptions: false, - texthooker: false, - help: false, - autoStartOverlay: false, - generateConfig: false, - backupOverwrite: false, - verbose: false, - }), - handleCliCommand: () => {}, - printHelp: () => {}, - logNoRunningInstance: () => {}, - onReady: async () => {}, - onWillQuitCleanup: () => {}, - shouldRestoreWindowsOnActivate: () => false, - restoreWindowsOnActivate: () => {}, - }); - - assert.equal(deps.requestSingleInstanceLock(), true); - deps.quitApp(); - assert.equal(lockRequested, 1); - assert.equal(quitCalled, 1); - assert.equal(deps.isDarwinPlatform(), true); - - let callbackRan = false; - deps.whenReady(async () => { - callbackRan = true; - }); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(callbackRan, true); - - deps.onActivate(() => {}); - assert.equal(typeof events["activate"], "function"); -}); diff --git a/src/core/services/app-lifecycle-deps-runtime-service.ts b/src/core/services/app-lifecycle-deps-runtime-service.ts deleted file mode 100644 index 127f249..0000000 --- a/src/core/services/app-lifecycle-deps-runtime-service.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { CliArgs, CliCommandSource } from "../../cli/args"; -import { AppLifecycleServiceDeps } from "./app-lifecycle-service"; - -interface AppLike { - requestSingleInstanceLock: () => boolean; - quit: () => void; - on: (...args: any[]) => unknown; - whenReady: () => Promise; -} - -export interface AppLifecycleDepsRuntimeOptions { - app: AppLike; - platform: NodeJS.Platform; - shouldStartApp: (args: CliArgs) => boolean; - parseArgs: (argv: string[]) => CliArgs; - handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; - printHelp: () => void; - logNoRunningInstance: () => void; - onReady: () => Promise; - onWillQuitCleanup: () => void; - shouldRestoreWindowsOnActivate: () => boolean; - restoreWindowsOnActivate: () => void; -} - -export function createAppLifecycleDepsRuntimeService( - options: AppLifecycleDepsRuntimeOptions, -): AppLifecycleServiceDeps { - return { - shouldStartApp: options.shouldStartApp, - parseArgs: options.parseArgs, - requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(), - quitApp: () => options.app.quit(), - onSecondInstance: (handler) => { - options.app.on("second-instance", handler as (...args: unknown[]) => void); - }, - handleCliCommand: options.handleCliCommand, - printHelp: options.printHelp, - logNoRunningInstance: options.logNoRunningInstance, - whenReady: (handler) => { - options.app.whenReady().then(handler); - }, - onWindowAllClosed: (handler) => { - options.app.on("window-all-closed", handler as (...args: unknown[]) => void); - }, - onWillQuit: (handler) => { - options.app.on("will-quit", handler as (...args: unknown[]) => void); - }, - onActivate: (handler) => { - options.app.on("activate", handler as (...args: unknown[]) => void); - }, - isDarwinPlatform: () => options.platform === "darwin", - onReady: options.onReady, - onWillQuitCleanup: options.onWillQuitCleanup, - shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate, - restoreWindowsOnActivate: options.restoreWindowsOnActivate, - }; -} diff --git a/src/core/services/app-lifecycle-service.ts b/src/core/services/app-lifecycle-service.ts index 2b11a7b..b4a795a 100644 --- a/src/core/services/app-lifecycle-service.ts +++ b/src/core/services/app-lifecycle-service.ts @@ -20,6 +20,63 @@ export interface AppLifecycleServiceDeps { restoreWindowsOnActivate: () => void; } +interface AppLike { + requestSingleInstanceLock: () => boolean; + quit: () => void; + on: (...args: any[]) => unknown; + whenReady: () => Promise; +} + +export interface AppLifecycleDepsRuntimeOptions { + app: AppLike; + platform: NodeJS.Platform; + shouldStartApp: (args: CliArgs) => boolean; + parseArgs: (argv: string[]) => CliArgs; + handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; + printHelp: () => void; + logNoRunningInstance: () => void; + onReady: () => Promise; + onWillQuitCleanup: () => void; + shouldRestoreWindowsOnActivate: () => boolean; + restoreWindowsOnActivate: () => void; +} + +export function createAppLifecycleDepsRuntimeService( + options: AppLifecycleDepsRuntimeOptions, +): AppLifecycleServiceDeps { + return { + shouldStartApp: options.shouldStartApp, + parseArgs: options.parseArgs, + requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(), + quitApp: () => options.app.quit(), + onSecondInstance: (handler) => { + options.app.on("second-instance", handler as (...args: unknown[]) => void); + }, + handleCliCommand: options.handleCliCommand, + printHelp: options.printHelp, + logNoRunningInstance: options.logNoRunningInstance, + whenReady: (handler) => { + options.app.whenReady().then(handler).catch((error) => { + console.error("App ready handler failed:", error); + }); + }, + onWindowAllClosed: (handler) => { + options.app.on("window-all-closed", handler as (...args: unknown[]) => void); + }, + onWillQuit: (handler) => { + options.app.on("will-quit", handler as (...args: unknown[]) => void); + }, + onActivate: (handler) => { + options.app.on("activate", handler as (...args: unknown[]) => void); + }, + isDarwinPlatform: () => options.platform === "darwin", + onReady: options.onReady, + onWillQuitCleanup: options.onWillQuitCleanup, + shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate, + restoreWindowsOnActivate: options.restoreWindowsOnActivate, + }; +} + export function startAppLifecycleService( initialArgs: CliArgs, deps: AppLifecycleServiceDeps, @@ -31,7 +88,11 @@ export function startAppLifecycleService( } deps.onSecondInstance((_event, argv) => { - deps.handleCliCommand(deps.parseArgs(argv), "second-instance"); + try { + deps.handleCliCommand(deps.parseArgs(argv), "second-instance"); + } catch (error) { + console.error("Failed to handle second-instance CLI command:", error); + } }); if (initialArgs.help && !deps.shouldStartApp(initialArgs)) { diff --git a/src/core/services/app-logging-runtime-service.test.ts b/src/core/services/app-logging-runtime-service.test.ts deleted file mode 100644 index 535345d..0000000 --- a/src/core/services/app-logging-runtime-service.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createAppLoggingRuntimeService } from "./app-logging-runtime-service"; - -test("createAppLoggingRuntimeService routes logs and formats config warnings", () => { - const lines: string[] = []; - const logger = { - log: (line: string) => lines.push(`log:${line}`), - warn: (line: string) => lines.push(`warn:${line}`), - error: (line: string) => lines.push(`error:${line}`), - }; - - const runtime = createAppLoggingRuntimeService(logger); - runtime.logInfo("hello"); - runtime.logWarning("careful"); - runtime.logNoRunningInstance(); - runtime.logConfigWarning({ - path: "x.y", - value: "bad", - fallback: "good", - message: "invalid", - }); - - assert.equal(lines[0], "log:hello"); - assert.equal(lines[1], "warn:careful"); - assert.equal(lines[2], "error:No running instance. Use --start to launch the app."); - assert.match(lines[3], /^warn:\[config\] x\.y: invalid /); -}); diff --git a/src/core/services/app-logging-runtime-service.ts b/src/core/services/app-logging-runtime-service.ts deleted file mode 100644 index d9522a9..0000000 --- a/src/core/services/app-logging-runtime-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ConfigValidationWarning } from "../../types"; -import { logConfigWarningRuntimeService } from "./config-warning-runtime-service"; - -export interface AppLoggingRuntime { - logInfo: (message: string) => void; - logWarning: (message: string) => void; - logNoRunningInstance: () => void; - logConfigWarning: (warning: ConfigValidationWarning) => void; -} - -export function createAppLoggingRuntimeService( - logger: Pick = console, -): AppLoggingRuntime { - return { - logInfo: (message) => { - logger.log(message); - }, - logWarning: (message) => { - logger.warn(message); - }, - logNoRunningInstance: () => { - logger.error("No running instance. Use --start to launch the app."); - }, - logConfigWarning: (warning) => { - logConfigWarningRuntimeService(warning, (line) => logger.warn(line)); - }, - }; -} diff --git a/src/core/services/app-ready-runtime-service.test.ts b/src/core/services/app-ready-service.test.ts similarity index 98% rename from src/core/services/app-ready-runtime-service.test.ts rename to src/core/services/app-ready-service.test.ts index 7c1190d..002d0a9 100644 --- a/src/core/services/app-ready-runtime-service.test.ts +++ b/src/core/services/app-ready-service.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { AppReadyRuntimeDeps, runAppReadyRuntimeService } from "./app-ready-runtime-service"; +import { AppReadyRuntimeDeps, runAppReadyRuntimeService } from "./startup-service"; function makeDeps(overrides: Partial = {}) { const calls: string[] = []; diff --git a/src/core/services/app-shutdown-runtime-service.test.ts b/src/core/services/app-shutdown-runtime-service.test.ts deleted file mode 100644 index aa45bea..0000000 --- a/src/core/services/app-shutdown-runtime-service.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { runAppShutdownRuntimeService } from "./app-shutdown-runtime-service"; - -test("runAppShutdownRuntimeService runs teardown steps in order", () => { - const calls: string[] = []; - runAppShutdownRuntimeService({ - unregisterAllGlobalShortcuts: () => calls.push("unregisterAllGlobalShortcuts"), - stopSubtitleWebsocket: () => calls.push("stopSubtitleWebsocket"), - stopTexthookerService: () => calls.push("stopTexthookerService"), - destroyYomitanParserWindow: () => calls.push("destroyYomitanParserWindow"), - clearYomitanParserPromises: () => calls.push("clearYomitanParserPromises"), - stopWindowTracker: () => calls.push("stopWindowTracker"), - destroyMpvSocket: () => calls.push("destroyMpvSocket"), - clearReconnectTimer: () => calls.push("clearReconnectTimer"), - destroySubtitleTimingTracker: () => calls.push("destroySubtitleTimingTracker"), - destroyAnkiIntegration: () => calls.push("destroyAnkiIntegration"), - }); - - assert.deepEqual(calls, [ - "unregisterAllGlobalShortcuts", - "stopSubtitleWebsocket", - "stopTexthookerService", - "destroyYomitanParserWindow", - "clearYomitanParserPromises", - "stopWindowTracker", - "destroyMpvSocket", - "clearReconnectTimer", - "destroySubtitleTimingTracker", - "destroyAnkiIntegration", - ]); -}); diff --git a/src/core/services/app-shutdown-runtime-service.ts b/src/core/services/app-shutdown-runtime-service.ts deleted file mode 100644 index 7680387..0000000 --- a/src/core/services/app-shutdown-runtime-service.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface AppShutdownRuntimeDeps { - unregisterAllGlobalShortcuts: () => void; - stopSubtitleWebsocket: () => void; - stopTexthookerService: () => void; - destroyYomitanParserWindow: () => void; - clearYomitanParserPromises: () => void; - stopWindowTracker: () => void; - destroyMpvSocket: () => void; - clearReconnectTimer: () => void; - destroySubtitleTimingTracker: () => void; - destroyAnkiIntegration: () => void; -} - -export function runAppShutdownRuntimeService( - deps: AppShutdownRuntimeDeps, -): void { - deps.unregisterAllGlobalShortcuts(); - deps.stopSubtitleWebsocket(); - deps.stopTexthookerService(); - deps.destroyYomitanParserWindow(); - deps.clearYomitanParserPromises(); - deps.stopWindowTracker(); - deps.destroyMpvSocket(); - deps.clearReconnectTimer(); - deps.destroySubtitleTimingTracker(); - deps.destroyAnkiIntegration(); -} diff --git a/src/core/services/cli-command-deps-runtime-service.test.ts b/src/core/services/cli-command-deps-runtime-service.test.ts deleted file mode 100644 index 81370fc..0000000 --- a/src/core/services/cli-command-deps-runtime-service.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createCliCommandDepsRuntimeService } from "./cli-command-deps-runtime-service"; - -test("createCliCommandDepsRuntimeService wires runtime helpers", () => { - let socketPath = "/tmp/mpv"; - let setClientSocketPath: string | null = null; - let connectCalls = 0; - let texthookerPort = 7000; - let texthookerRunning = false; - let texthookerStartPort: number | null = null; - let overlayVisible = false; - let overlayInvisible = false; - let openYomitanAfterDelay: number | null = null; - - const deps = createCliCommandDepsRuntimeService({ - mpv: { - getSocketPath: () => socketPath, - setSocketPath: (next) => { - socketPath = next; - }, - getClient: () => ({ - setSocketPath: (next) => { - setClientSocketPath = next; - }, - connect: () => { - connectCalls += 1; - }, - }), - showOsd: () => {}, - }, - texthooker: { - service: { - isRunning: () => texthookerRunning, - start: (port) => { - texthookerRunning = true; - texthookerStartPort = port; - }, - }, - getPort: () => texthookerPort, - setPort: (port) => { - texthookerPort = port; - }, - shouldOpenBrowser: () => true, - openInBrowser: () => {}, - }, - overlay: { - isInitialized: () => false, - initialize: () => {}, - toggleVisible: () => { - overlayVisible = !overlayVisible; - }, - toggleInvisible: () => { - overlayInvisible = !overlayInvisible; - }, - setVisible: (visible) => { - overlayVisible = visible; - }, - setInvisible: (visible) => { - overlayInvisible = visible; - }, - }, - mining: { - copyCurrentSubtitle: () => {}, - startPendingMultiCopy: () => {}, - mineSentenceCard: async () => {}, - startPendingMineSentenceMultiple: () => {}, - updateLastCardFromClipboard: async () => {}, - triggerFieldGrouping: async () => {}, - triggerSubsyncFromConfig: async () => {}, - markLastCardAsAudioCard: async () => {}, - }, - ui: { - openYomitanSettings: () => {}, - cycleSecondarySubMode: () => {}, - openRuntimeOptionsPalette: () => {}, - printHelp: () => {}, - }, - app: { - stop: () => {}, - hasMainWindow: () => true, - }, - getMultiCopyTimeoutMs: () => 2500, - schedule: (_fn, delayMs) => { - openYomitanAfterDelay = delayMs; - return null; - }, - log: () => {}, - warn: () => {}, - error: () => {}, - }); - - deps.setMpvSocketPath("/tmp/new"); - deps.setMpvClientSocketPath("/tmp/new"); - deps.connectMpvClient(); - deps.ensureTexthookerRunning(9000); - deps.openYomitanSettingsDelayed(1000); - deps.toggleVisibleOverlay(); - deps.toggleInvisibleOverlay(); - - assert.equal(deps.getMpvSocketPath(), "/tmp/new"); - assert.equal(setClientSocketPath, "/tmp/new"); - assert.equal(connectCalls, 1); - assert.equal(texthookerStartPort, 9000); - assert.equal(texthookerPort, 7000); - assert.equal(openYomitanAfterDelay, 1000); - assert.equal(overlayVisible, true); - assert.equal(overlayInvisible, true); - assert.equal(deps.getMultiCopyTimeoutMs(), 2500); -}); diff --git a/src/core/services/cli-command-deps-runtime-service.ts b/src/core/services/cli-command-deps-runtime-service.ts deleted file mode 100644 index 7bb06d4..0000000 --- a/src/core/services/cli-command-deps-runtime-service.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { CliCommandServiceDeps } from "./cli-command-service"; - -interface MpvClientLike { - setSocketPath: (socketPath: string) => void; - connect: () => void; -} - -interface TexthookerServiceLike { - isRunning: () => boolean; - start: (port: number) => void; -} - -interface MpvCliRuntime { - getSocketPath: () => string; - setSocketPath: (socketPath: string) => void; - getClient: () => MpvClientLike | null; - showOsd: (text: string) => void; -} - -interface TexthookerCliRuntime { - service: TexthookerServiceLike; - getPort: () => number; - setPort: (port: number) => void; - shouldOpenBrowser: () => boolean; - openInBrowser: (url: string) => void; -} - -interface OverlayCliRuntime { - isInitialized: () => boolean; - initialize: () => void; - toggleVisible: () => void; - toggleInvisible: () => void; - setVisible: (visible: boolean) => void; - setInvisible: (visible: boolean) => void; -} - -interface MiningCliRuntime { - copyCurrentSubtitle: () => void; - startPendingMultiCopy: (timeoutMs: number) => void; - mineSentenceCard: () => Promise; - startPendingMineSentenceMultiple: (timeoutMs: number) => void; - updateLastCardFromClipboard: () => Promise; - triggerFieldGrouping: () => Promise; - triggerSubsyncFromConfig: () => Promise; - markLastCardAsAudioCard: () => Promise; -} - -interface UiCliRuntime { - openYomitanSettings: () => void; - cycleSecondarySubMode: () => void; - openRuntimeOptionsPalette: () => void; - printHelp: () => void; -} - -interface AppCliRuntime { - stop: () => void; - hasMainWindow: () => boolean; -} - -export interface CliCommandDepsRuntimeOptions { - mpv: MpvCliRuntime; - texthooker: TexthookerCliRuntime; - overlay: OverlayCliRuntime; - mining: MiningCliRuntime; - ui: UiCliRuntime; - app: AppCliRuntime; - getMultiCopyTimeoutMs: () => number; - schedule: (fn: () => void, delayMs: number) => unknown; - log: (message: string) => void; - warn: (message: string) => void; - error: (message: string, err: unknown) => void; -} - -export function createCliCommandDepsRuntimeService( - options: CliCommandDepsRuntimeOptions, -): CliCommandServiceDeps { - return { - getMpvSocketPath: options.mpv.getSocketPath, - setMpvSocketPath: options.mpv.setSocketPath, - setMpvClientSocketPath: (socketPath) => { - const client = options.mpv.getClient(); - if (!client) return; - client.setSocketPath(socketPath); - }, - hasMpvClient: () => Boolean(options.mpv.getClient()), - connectMpvClient: () => { - const client = options.mpv.getClient(); - if (!client) return; - client.connect(); - }, - isTexthookerRunning: () => options.texthooker.service.isRunning(), - setTexthookerPort: options.texthooker.setPort, - getTexthookerPort: options.texthooker.getPort, - shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser, - ensureTexthookerRunning: (port) => { - if (!options.texthooker.service.isRunning()) { - options.texthooker.service.start(port); - } - }, - openTexthookerInBrowser: options.texthooker.openInBrowser, - stopApp: options.app.stop, - isOverlayRuntimeInitialized: options.overlay.isInitialized, - initializeOverlayRuntime: options.overlay.initialize, - toggleVisibleOverlay: options.overlay.toggleVisible, - toggleInvisibleOverlay: options.overlay.toggleInvisible, - openYomitanSettingsDelayed: (delayMs) => { - options.schedule(() => { - options.ui.openYomitanSettings(); - }, delayMs); - }, - setVisibleOverlayVisible: options.overlay.setVisible, - setInvisibleOverlayVisible: options.overlay.setInvisible, - copyCurrentSubtitle: options.mining.copyCurrentSubtitle, - startPendingMultiCopy: options.mining.startPendingMultiCopy, - mineSentenceCard: options.mining.mineSentenceCard, - startPendingMineSentenceMultiple: - options.mining.startPendingMineSentenceMultiple, - updateLastCardFromClipboard: options.mining.updateLastCardFromClipboard, - cycleSecondarySubMode: options.ui.cycleSecondarySubMode, - triggerFieldGrouping: options.mining.triggerFieldGrouping, - triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig, - markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard, - openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette, - printHelp: options.ui.printHelp, - hasMainWindow: options.app.hasMainWindow, - getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, - showMpvOsd: options.mpv.showOsd, - log: options.log, - warn: options.warn, - error: options.error, - }; -} diff --git a/src/core/services/cli-command-service.ts b/src/core/services/cli-command-service.ts index 5e592e3..ac0fa26 100644 --- a/src/core/services/cli-command-service.ts +++ b/src/core/services/cli-command-service.ts @@ -43,6 +43,137 @@ export interface CliCommandServiceDeps { error: (message: string, err: unknown) => void; } +interface MpvClientLike { + setSocketPath: (socketPath: string) => void; + connect: () => void; +} + +interface TexthookerServiceLike { + isRunning: () => boolean; + start: (port: number) => void; +} + +interface MpvCliRuntime { + getSocketPath: () => string; + setSocketPath: (socketPath: string) => void; + getClient: () => MpvClientLike | null; + showOsd: (text: string) => void; +} + +interface TexthookerCliRuntime { + service: TexthookerServiceLike; + getPort: () => number; + setPort: (port: number) => void; + shouldOpenBrowser: () => boolean; + openInBrowser: (url: string) => void; +} + +interface OverlayCliRuntime { + isInitialized: () => boolean; + initialize: () => void; + toggleVisible: () => void; + toggleInvisible: () => void; + setVisible: (visible: boolean) => void; + setInvisible: (visible: boolean) => void; +} + +interface MiningCliRuntime { + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; +} + +interface UiCliRuntime { + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; +} + +interface AppCliRuntime { + stop: () => void; + hasMainWindow: () => boolean; +} + +export interface CliCommandDepsRuntimeOptions { + mpv: MpvCliRuntime; + texthooker: TexthookerCliRuntime; + overlay: OverlayCliRuntime; + mining: MiningCliRuntime; + ui: UiCliRuntime; + app: AppCliRuntime; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => unknown; + log: (message: string) => void; + warn: (message: string) => void; + error: (message: string, err: unknown) => void; +} + +export function createCliCommandDepsRuntimeService( + options: CliCommandDepsRuntimeOptions, +): CliCommandServiceDeps { + return { + getMpvSocketPath: options.mpv.getSocketPath, + setMpvSocketPath: options.mpv.setSocketPath, + setMpvClientSocketPath: (socketPath) => { + const client = options.mpv.getClient(); + if (!client) return; + client.setSocketPath(socketPath); + }, + hasMpvClient: () => Boolean(options.mpv.getClient()), + connectMpvClient: () => { + const client = options.mpv.getClient(); + if (!client) return; + client.connect(); + }, + isTexthookerRunning: () => options.texthooker.service.isRunning(), + setTexthookerPort: options.texthooker.setPort, + getTexthookerPort: options.texthooker.getPort, + shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser, + ensureTexthookerRunning: (port) => { + if (!options.texthooker.service.isRunning()) { + options.texthooker.service.start(port); + } + }, + openTexthookerInBrowser: options.texthooker.openInBrowser, + stopApp: options.app.stop, + isOverlayRuntimeInitialized: options.overlay.isInitialized, + initializeOverlayRuntime: options.overlay.initialize, + toggleVisibleOverlay: options.overlay.toggleVisible, + toggleInvisibleOverlay: options.overlay.toggleInvisible, + openYomitanSettingsDelayed: (delayMs) => { + options.schedule(() => { + options.ui.openYomitanSettings(); + }, delayMs); + }, + setVisibleOverlayVisible: options.overlay.setVisible, + setInvisibleOverlayVisible: options.overlay.setInvisible, + copyCurrentSubtitle: options.mining.copyCurrentSubtitle, + startPendingMultiCopy: options.mining.startPendingMultiCopy, + mineSentenceCard: options.mining.mineSentenceCard, + startPendingMineSentenceMultiple: + options.mining.startPendingMineSentenceMultiple, + updateLastCardFromClipboard: options.mining.updateLastCardFromClipboard, + cycleSecondarySubMode: options.ui.cycleSecondarySubMode, + triggerFieldGrouping: options.mining.triggerFieldGrouping, + triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig, + markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard, + openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette, + printHelp: options.ui.printHelp, + hasMainWindow: options.app.hasMainWindow, + getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, + showMpvOsd: options.mpv.showOsd, + log: options.log, + warn: options.warn, + error: options.error, + }; +} + function runAsyncWithOsd( task: () => Promise, deps: CliCommandServiceDeps, diff --git a/src/core/services/config-generation-runtime-service.test.ts b/src/core/services/config-generation-runtime-service.test.ts deleted file mode 100644 index 2551292..0000000 --- a/src/core/services/config-generation-runtime-service.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { runGenerateConfigFlowRuntimeService } from "./config-generation-runtime-service"; -import { CliArgs } from "../../cli/args"; - -function makeArgs(overrides: Partial = {}): CliArgs { - return { - start: false, - stop: false, - toggle: false, - toggleVisibleOverlay: false, - toggleInvisibleOverlay: false, - settings: false, - show: false, - hide: false, - showVisibleOverlay: false, - hideVisibleOverlay: false, - showInvisibleOverlay: false, - hideInvisibleOverlay: false, - copySubtitle: false, - copySubtitleMultiple: false, - mineSentence: false, - mineSentenceMultiple: false, - updateLastCardFromClipboard: false, - toggleSecondarySub: false, - triggerFieldGrouping: false, - triggerSubsync: false, - markAudioCard: false, - openRuntimeOptions: false, - texthooker: false, - help: false, - autoStartOverlay: false, - generateConfig: false, - backupOverwrite: false, - verbose: false, - ...overrides, - }; -} - -test("runGenerateConfigFlowRuntimeService starts flow when generateConfig is set and app should not start", async () => { - const calls: string[] = []; - const handled = runGenerateConfigFlowRuntimeService( - makeArgs({ generateConfig: true }), - { - shouldStartApp: () => false, - generateConfig: async () => 7, - onSuccess: (code) => calls.push(`success:${code}`), - onError: () => calls.push("error"), - }, - ); - assert.equal(handled, true); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(calls, ["success:7"]); -}); - -test("runGenerateConfigFlowRuntimeService returns false when flow should not run", () => { - const handled = runGenerateConfigFlowRuntimeService( - makeArgs({ generateConfig: true, start: true }), - { - shouldStartApp: () => true, - generateConfig: async () => 0, - onSuccess: () => {}, - onError: () => {}, - }, - ); - assert.equal(handled, false); -}); diff --git a/src/core/services/config-generation-runtime-service.ts b/src/core/services/config-generation-runtime-service.ts deleted file mode 100644 index 14b34c6..0000000 --- a/src/core/services/config-generation-runtime-service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CliArgs } from "../../cli/args"; - -export interface ConfigGenerationRuntimeDeps { - shouldStartApp: (args: CliArgs) => boolean; - generateConfig: (args: CliArgs) => Promise; - onSuccess: (exitCode: number) => void; - onError: (error: Error) => void; -} - -export function runGenerateConfigFlowRuntimeService( - args: CliArgs, - deps: ConfigGenerationRuntimeDeps, -): boolean { - if (!args.generateConfig || deps.shouldStartApp(args)) { - return false; - } - - deps.generateConfig(args) - .then((exitCode) => { - deps.onSuccess(exitCode); - }) - .catch((error: Error) => { - deps.onError(error); - }); - return true; -} diff --git a/src/core/services/config-warning-runtime-service.test.ts b/src/core/services/config-warning-runtime-service.test.ts deleted file mode 100644 index 8181886..0000000 --- a/src/core/services/config-warning-runtime-service.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - formatConfigWarningRuntimeService, - logConfigWarningRuntimeService, -} from "./config-warning-runtime-service"; - -test("formatConfigWarningRuntimeService formats warning line", () => { - const message = formatConfigWarningRuntimeService({ - path: "ankiConnect.enabled", - value: "oops", - fallback: true, - message: "invalid type", - }); - assert.equal( - message, - '[config] ankiConnect.enabled: invalid type value="oops" fallback=true', - ); -}); - -test("logConfigWarningRuntimeService delegates to logger", () => { - const logs: string[] = []; - logConfigWarningRuntimeService( - { - path: "x.y", - value: 1, - fallback: 2, - message: "bad", - }, - (line) => logs.push(line), - ); - assert.equal(logs.length, 1); - assert.match(logs[0], /^\[config\] x\.y: bad /); -}); diff --git a/src/core/services/config-warning-runtime-service.ts b/src/core/services/config-warning-runtime-service.ts deleted file mode 100644 index 5cc44bd..0000000 --- a/src/core/services/config-warning-runtime-service.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ConfigValidationWarning } from "../../types"; - -export function formatConfigWarningRuntimeService( - warning: ConfigValidationWarning, -): string { - return `[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`; -} - -export function logConfigWarningRuntimeService( - warning: ConfigValidationWarning, - log: (message: string) => void, -): void { - log(formatConfigWarningRuntimeService(warning)); -} diff --git a/src/core/services/field-grouping-overlay-runtime-service.test.ts b/src/core/services/field-grouping-overlay-service.test.ts similarity index 98% rename from src/core/services/field-grouping-overlay-runtime-service.test.ts rename to src/core/services/field-grouping-overlay-service.test.ts index a6ff621..9689332 100644 --- a/src/core/services/field-grouping-overlay-runtime-service.test.ts +++ b/src/core/services/field-grouping-overlay-service.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { KikuFieldGroupingChoice } from "../../types"; -import { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-runtime-service"; +import { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service"; test("createFieldGroupingOverlayRuntimeService sends overlay messages and sets restore flag", () => { const sent: unknown[][] = []; diff --git a/src/core/services/field-grouping-overlay-runtime-service.ts b/src/core/services/field-grouping-overlay-service.ts similarity index 98% rename from src/core/services/field-grouping-overlay-runtime-service.ts rename to src/core/services/field-grouping-overlay-service.ts index 21f4d6b..3c00fe8 100644 --- a/src/core/services/field-grouping-overlay-runtime-service.ts +++ b/src/core/services/field-grouping-overlay-service.ts @@ -5,7 +5,7 @@ import { import { createFieldGroupingCallbackRuntimeService, sendToVisibleOverlayRuntimeService, -} from "./overlay-bridge-runtime-service"; +} from "./overlay-bridge-service"; interface WindowLike { isDestroyed: () => boolean; diff --git a/src/core/services/field-grouping-service.ts b/src/core/services/field-grouping-service.ts index 431074e..6c0fbe7 100644 --- a/src/core/services/field-grouping-service.ts +++ b/src/core/services/field-grouping-service.ts @@ -16,6 +16,16 @@ export function createFieldGroupingCallbackService(options: { data: KikuFieldGroupingRequestData, ): Promise => { return new Promise((resolve) => { + if (options.getResolver()) { + resolve({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + return; + } + const previousVisibleOverlay = options.getVisibleOverlayVisible(); const previousInvisibleOverlay = options.getInvisibleOverlayVisible(); let settled = false; @@ -23,7 +33,9 @@ export function createFieldGroupingCallbackService(options: { const finish = (choice: KikuFieldGroupingChoice): void => { if (settled) return; settled = true; - options.setResolver(null); + if (options.getResolver() === finish) { + options.setResolver(null); + } resolve(choice); if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) { diff --git a/src/core/services/index.ts b/src/core/services/index.ts index c0be5b7..abb8e17 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -1,17 +1,17 @@ export { TexthookerService } from "./texthooker-service"; export { hasMpvWebsocketPlugin, SubtitleWebSocketService } from "./subtitle-ws-service"; export { registerGlobalShortcutsService } from "./shortcut-service"; -export { registerIpcHandlersService } from "./ipc-service"; +export { createIpcDepsRuntimeService, registerIpcHandlersService } from "./ipc-service"; export { isGlobalShortcutRegisteredSafe, shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service"; -export { registerOverlayShortcutsService } from "./overlay-shortcut-service"; -export { createOverlayShortcutRuntimeHandlers } from "./overlay-shortcut-runtime-service"; -export { handleCliCommandService } from "./cli-command-service"; -export { cycleSecondarySubModeService } from "./secondary-subtitle-service"; export { refreshOverlayShortcutsRuntimeService, + registerOverlayShortcutsService, syncOverlayShortcutsRuntimeService, unregisterOverlayShortcutsRuntimeService, -} from "./overlay-shortcut-lifecycle-service"; +} from "./overlay-shortcut-service"; +export { createOverlayShortcutRuntimeHandlers } from "./overlay-shortcut-handler"; +export { createCliCommandDepsRuntimeService, handleCliCommandService } from "./cli-command-service"; +export { cycleSecondarySubModeService } from "./secondary-subtitle-service"; export { copyCurrentSubtitleService, handleMineSentenceDigitService, @@ -20,15 +20,15 @@ export { mineSentenceCardService, triggerFieldGroupingService, updateLastCardFromClipboardService, -} from "./mining-runtime-service"; -export { startAppLifecycleService } from "./app-lifecycle-service"; +} from "./mining-service"; +export { createAppLifecycleDepsRuntimeService, startAppLifecycleService } from "./app-lifecycle-service"; export { playNextSubtitleRuntimeService, replayCurrentSubtitleRuntimeService, sendMpvCommandRuntimeService, setMpvSubVisibilityRuntimeService, showMpvOsdRuntimeService, -} from "./mpv-runtime-service"; +} from "./mpv-control-service"; export { getInitialInvisibleOverlayVisibilityService, isAutoUpdateEnabledRuntimeService, @@ -36,14 +36,14 @@ export { shouldBindVisibleOverlayToMpvSubVisibilityService, } from "./runtime-config-service"; export { openYomitanSettingsWindow } from "./yomitan-settings-service"; -export { tokenizeSubtitleService } from "./tokenizer-service"; +export { createTokenizerDepsRuntimeService, tokenizeSubtitleService } from "./tokenizer-service"; export { loadYomitanExtensionService } from "./yomitan-extension-loader-service"; export { getJimakuLanguagePreferenceService, getJimakuMaxEntryResultsService, jimakuFetchJsonService, resolveJimakuApiKeyService, -} from "./jimaku-runtime-service"; +} from "./jimaku-service"; export { loadSubtitlePositionService, saveSubtitlePositionService, @@ -60,33 +60,19 @@ export { setInvisibleOverlayVisibleService, setVisibleOverlayVisibleService, syncInvisibleOverlayMousePassthroughService, -} from "./overlay-visibility-runtime-service"; + updateInvisibleOverlayVisibilityService, + updateVisibleOverlayVisibilityService, +} from "./overlay-visibility-service"; export { MpvIpcClient, MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from "./mpv-service"; export { applyMpvSubtitleRenderMetricsPatchService } from "./mpv-render-metrics-service"; export { handleMpvCommandFromIpcService } from "./ipc-command-service"; -export { handleOverlayModalClosedService } from "./overlay-modal-restore-service"; +export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service"; +export { createNumericShortcutRuntimeService } from "./numeric-shortcut-service"; +export { runStartupBootstrapRuntimeService } from "./startup-service"; +export { runSubsyncManualFromIpcRuntimeService, triggerSubsyncFromConfigRuntimeService } from "./subsync-runner-service"; +export { registerAnkiJimakuIpcRuntimeService } from "./anki-jimaku-service"; export { broadcastRuntimeOptionsChangedRuntimeService, - broadcastToOverlayWindowsRuntimeService, - getOverlayWindowsRuntimeService, + createOverlayManagerService, setOverlayDebugVisualizationEnabledRuntimeService, -} from "./overlay-broadcast-runtime-service"; -export { createAppLifecycleDepsRuntimeService } from "./app-lifecycle-deps-runtime-service"; -export { createCliCommandDepsRuntimeService } from "./cli-command-deps-runtime-service"; -export { createIpcDepsRuntimeService } from "./ipc-deps-runtime-service"; -export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-runtime-service"; -export { createNumericShortcutRuntimeService } from "./numeric-shortcut-runtime-service"; -export { createTokenizerDepsRuntimeService } from "./tokenizer-deps-runtime-service"; -export { runOverlayShortcutLocalFallbackRuntimeService } from "./shortcut-ui-deps-runtime-service"; -export { createRuntimeOptionsManagerRuntimeService } from "./runtime-options-manager-runtime-service"; -export { createAppLoggingRuntimeService } from "./app-logging-runtime-service"; -export { - createMecabTokenizerAndCheckRuntimeService, - createSubtitleTimingTrackerRuntimeService, -} from "./startup-resource-runtime-service"; -export { runGenerateConfigFlowRuntimeService } from "./config-generation-runtime-service"; -export { runStartupBootstrapRuntimeService } from "./startup-bootstrap-runtime-service"; -export { runSubsyncManualFromIpcRuntimeService, triggerSubsyncFromConfigRuntimeService } from "./subsync-runtime-service"; -export { updateInvisibleOverlayVisibilityService, updateVisibleOverlayVisibilityService } from "./overlay-visibility-service"; -export { registerAnkiJimakuIpcRuntimeService } from "./anki-jimaku-runtime-service"; -export { createOverlayManagerService } from "./overlay-manager-service"; +} from "./overlay-manager-service"; diff --git a/src/core/services/ipc-deps-runtime-service.test.ts b/src/core/services/ipc-deps-runtime-service.test.ts deleted file mode 100644 index 66650db..0000000 --- a/src/core/services/ipc-deps-runtime-service.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createIpcDepsRuntimeService } from "./ipc-deps-runtime-service"; - -test("createIpcDepsRuntimeService maps window and mecab helpers", async () => { - let ignoreMouse: { ignore: boolean; forward?: boolean } | null = null; - let toggledDevTools = 0; - let mecabEnabled: boolean | null = null; - - const visibleWindow = { - isDestroyed: () => false, - setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { - ignoreMouse = { ignore, forward: options?.forward }; - }, - webContents: { - toggleDevTools: () => { - toggledDevTools += 1; - }, - }, - }; - - const deps = createIpcDepsRuntimeService({ - getInvisibleWindow: () => visibleWindow, - getMainWindow: () => visibleWindow, - getVisibleOverlayVisibility: () => true, - getInvisibleOverlayVisibility: () => false, - onOverlayModalClosed: () => {}, - openYomitanSettings: () => {}, - quitApp: () => {}, - toggleVisibleOverlay: () => {}, - tokenizeCurrentSubtitle: async () => ({ text: "x" }), - getCurrentSubtitleAss: () => "ass", - getMpvSubtitleRenderMetrics: () => ({ subPos: 100 }), - getSubtitlePosition: () => ({ x: 1, y: 2 }), - getSubtitleStyle: () => null, - saveSubtitlePosition: () => {}, - getMecabTokenizer: () => ({ - getStatus: () => ({ available: true, enabled: true, path: "/usr/bin/mecab" }), - setEnabled: (enabled: boolean) => { - mecabEnabled = enabled; - }, - }), - handleMpvCommand: () => {}, - getKeybindings: () => ({ copySubtitle: ["C"] }), - getSecondarySubMode: () => "hidden", - getMpvClient: () => ({ currentSecondarySubText: "secondary" }), - runSubsyncManual: async () => ({ ok: true }), - getAnkiConnectStatus: () => true, - getRuntimeOptions: () => ({ values: {} }), - setRuntimeOption: () => ({ ok: true }), - cycleRuntimeOption: () => ({ ok: true }), - }); - - deps.setInvisibleIgnoreMouseEvents(true, { forward: true }); - deps.toggleDevTools(); - deps.setMecabEnabled(false); - - assert.deepEqual(ignoreMouse, { ignore: true, forward: true }); - assert.equal(toggledDevTools, 1); - assert.equal(mecabEnabled, false); - assert.deepEqual(deps.getMecabStatus(), { - available: true, - enabled: true, - path: "/usr/bin/mecab", - }); - assert.equal(deps.getCurrentSecondarySub(), "secondary"); - assert.deepEqual(await deps.tokenizeCurrentSubtitle(), { text: "x" }); -}); - -test("createIpcDepsRuntimeService handles missing optional runtime resources", () => { - const deps = createIpcDepsRuntimeService({ - getInvisibleWindow: () => null, - getMainWindow: () => null, - getVisibleOverlayVisibility: () => false, - getInvisibleOverlayVisibility: () => false, - onOverlayModalClosed: () => {}, - openYomitanSettings: () => {}, - quitApp: () => {}, - toggleVisibleOverlay: () => {}, - tokenizeCurrentSubtitle: async () => null, - getCurrentSubtitleAss: () => "", - getMpvSubtitleRenderMetrics: () => null, - getSubtitlePosition: () => null, - getSubtitleStyle: () => null, - saveSubtitlePosition: () => {}, - getMecabTokenizer: () => null, - handleMpvCommand: () => {}, - getKeybindings: () => null, - getSecondarySubMode: () => "hidden", - getMpvClient: () => null, - runSubsyncManual: async () => ({ ok: false }), - getAnkiConnectStatus: () => false, - getRuntimeOptions: () => null, - setRuntimeOption: () => ({ ok: false }), - cycleRuntimeOption: () => ({ ok: false }), - }); - - deps.setInvisibleIgnoreMouseEvents(true, { forward: true }); - deps.toggleDevTools(); - deps.setMecabEnabled(true); - - assert.deepEqual(deps.getMecabStatus(), { - available: false, - enabled: false, - path: null, - }); - assert.equal(deps.getCurrentSecondarySub(), ""); -}); diff --git a/src/core/services/ipc-deps-runtime-service.ts b/src/core/services/ipc-deps-runtime-service.ts deleted file mode 100644 index 24ff129..0000000 --- a/src/core/services/ipc-deps-runtime-service.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { IpcServiceDeps } from "./ipc-service"; - -interface WindowLike { - isDestroyed: () => boolean; - setIgnoreMouseEvents: ( - ignore: boolean, - options?: { forward?: boolean }, - ) => void; - webContents: { - toggleDevTools: () => void; - }; -} - -interface MecabTokenizerLike { - getStatus: () => { available: boolean; enabled: boolean; path: string | null }; - setEnabled: (enabled: boolean) => void; -} - -interface MpvClientLike { - currentSecondarySubText?: string; -} - -export interface IpcDepsRuntimeOptions { - getInvisibleWindow: () => WindowLike | null; - getMainWindow: () => WindowLike | null; - getVisibleOverlayVisibility: () => boolean; - getInvisibleOverlayVisibility: () => boolean; - onOverlayModalClosed: (modal: string) => void; - openYomitanSettings: () => void; - quitApp: () => void; - toggleVisibleOverlay: () => void; - tokenizeCurrentSubtitle: () => Promise; - getCurrentSubtitleAss: () => string; - getMpvSubtitleRenderMetrics: () => unknown; - getSubtitlePosition: () => unknown; - getSubtitleStyle: () => unknown; - saveSubtitlePosition: (position: unknown) => void; - getMecabTokenizer: () => MecabTokenizerLike | null; - handleMpvCommand: (command: Array) => void; - getKeybindings: () => unknown; - getSecondarySubMode: () => unknown; - getMpvClient: () => MpvClientLike | null; - runSubsyncManual: (request: unknown) => Promise; - getAnkiConnectStatus: () => boolean; - getRuntimeOptions: () => unknown; - setRuntimeOption: (id: string, value: unknown) => unknown; - cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; -} - -export function createIpcDepsRuntimeService( - options: IpcDepsRuntimeOptions, -): IpcServiceDeps { - return { - getInvisibleWindow: () => options.getInvisibleWindow() as never, - isVisibleOverlayVisible: options.getVisibleOverlayVisibility, - setInvisibleIgnoreMouseEvents: (ignore, eventsOptions) => { - const invisibleWindow = options.getInvisibleWindow(); - if (!invisibleWindow || invisibleWindow.isDestroyed()) return; - invisibleWindow.setIgnoreMouseEvents(ignore, eventsOptions); - }, - onOverlayModalClosed: options.onOverlayModalClosed, - openYomitanSettings: options.openYomitanSettings, - quitApp: options.quitApp, - toggleDevTools: () => { - const mainWindow = options.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - mainWindow.webContents.toggleDevTools(); - }, - getVisibleOverlayVisibility: options.getVisibleOverlayVisibility, - toggleVisibleOverlay: options.toggleVisibleOverlay, - getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility, - tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, - getCurrentSubtitleAss: options.getCurrentSubtitleAss, - getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics, - getSubtitlePosition: options.getSubtitlePosition, - getSubtitleStyle: options.getSubtitleStyle, - saveSubtitlePosition: options.saveSubtitlePosition, - getMecabStatus: () => { - const mecabTokenizer = options.getMecabTokenizer(); - return mecabTokenizer - ? mecabTokenizer.getStatus() - : { available: false, enabled: false, path: null }; - }, - setMecabEnabled: (enabled) => { - const mecabTokenizer = options.getMecabTokenizer(); - if (!mecabTokenizer) return; - mecabTokenizer.setEnabled(enabled); - }, - handleMpvCommand: options.handleMpvCommand, - getKeybindings: options.getKeybindings, - getSecondarySubMode: options.getSecondarySubMode, - getCurrentSecondarySub: () => - options.getMpvClient()?.currentSecondarySubText || "", - runSubsyncManual: options.runSubsyncManual, - getAnkiConnectStatus: options.getAnkiConnectStatus, - getRuntimeOptions: options.getRuntimeOptions, - setRuntimeOption: options.setRuntimeOption, - cycleRuntimeOption: options.cycleRuntimeOption, - }; -} diff --git a/src/core/services/ipc-service.ts b/src/core/services/ipc-service.ts index 4801fdd..137afa4 100644 --- a/src/core/services/ipc-service.ts +++ b/src/core/services/ipc-service.ts @@ -1,7 +1,7 @@ import { BrowserWindow, ipcMain, IpcMainEvent } from "electron"; export interface IpcServiceDeps { - getInvisibleWindow: () => BrowserWindow | null; + getInvisibleWindow: () => WindowLike | null; isVisibleOverlayVisible: () => boolean; setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; onOverlayModalClosed: (modal: string) => void; @@ -30,6 +30,105 @@ export interface IpcServiceDeps { cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; } +interface WindowLike { + isDestroyed: () => boolean; + setIgnoreMouseEvents: ( + ignore: boolean, + options?: { forward?: boolean }, + ) => void; + webContents: { + toggleDevTools: () => void; + }; +} + +interface MecabTokenizerLike { + getStatus: () => { available: boolean; enabled: boolean; path: string | null }; + setEnabled: (enabled: boolean) => void; +} + +interface MpvClientLike { + currentSecondarySubText?: string; +} + +export interface IpcDepsRuntimeOptions { + getInvisibleWindow: () => WindowLike | null; + getMainWindow: () => WindowLike | null; + getVisibleOverlayVisibility: () => boolean; + getInvisibleOverlayVisibility: () => boolean; + onOverlayModalClosed: (modal: string) => void; + openYomitanSettings: () => void; + quitApp: () => void; + toggleVisibleOverlay: () => void; + tokenizeCurrentSubtitle: () => Promise; + getCurrentSubtitleAss: () => string; + getMpvSubtitleRenderMetrics: () => unknown; + getSubtitlePosition: () => unknown; + getSubtitleStyle: () => unknown; + saveSubtitlePosition: (position: unknown) => void; + getMecabTokenizer: () => MecabTokenizerLike | null; + handleMpvCommand: (command: Array) => void; + getKeybindings: () => unknown; + getSecondarySubMode: () => unknown; + getMpvClient: () => MpvClientLike | null; + runSubsyncManual: (request: unknown) => Promise; + getAnkiConnectStatus: () => boolean; + getRuntimeOptions: () => unknown; + setRuntimeOption: (id: string, value: unknown) => unknown; + cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; +} + +export function createIpcDepsRuntimeService( + options: IpcDepsRuntimeOptions, +): IpcServiceDeps { + return { + getInvisibleWindow: () => options.getInvisibleWindow(), + isVisibleOverlayVisible: options.getVisibleOverlayVisibility, + setInvisibleIgnoreMouseEvents: (ignore, eventsOptions) => { + const invisibleWindow = options.getInvisibleWindow(); + if (!invisibleWindow || invisibleWindow.isDestroyed()) return; + invisibleWindow.setIgnoreMouseEvents(ignore, eventsOptions); + }, + onOverlayModalClosed: options.onOverlayModalClosed, + openYomitanSettings: options.openYomitanSettings, + quitApp: options.quitApp, + toggleDevTools: () => { + const mainWindow = options.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.toggleDevTools(); + }, + getVisibleOverlayVisibility: options.getVisibleOverlayVisibility, + toggleVisibleOverlay: options.toggleVisibleOverlay, + getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility, + tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, + getCurrentSubtitleAss: options.getCurrentSubtitleAss, + getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics, + getSubtitlePosition: options.getSubtitlePosition, + getSubtitleStyle: options.getSubtitleStyle, + saveSubtitlePosition: options.saveSubtitlePosition, + getMecabStatus: () => { + const mecabTokenizer = options.getMecabTokenizer(); + return mecabTokenizer + ? mecabTokenizer.getStatus() + : { available: false, enabled: false, path: null }; + }, + setMecabEnabled: (enabled) => { + const mecabTokenizer = options.getMecabTokenizer(); + if (!mecabTokenizer) return; + mecabTokenizer.setEnabled(enabled); + }, + handleMpvCommand: options.handleMpvCommand, + getKeybindings: options.getKeybindings, + getSecondarySubMode: options.getSecondarySubMode, + getCurrentSecondarySub: () => + options.getMpvClient()?.currentSecondarySubText || "", + runSubsyncManual: options.runSubsyncManual, + getAnkiConnectStatus: options.getAnkiConnectStatus, + getRuntimeOptions: options.getRuntimeOptions, + setRuntimeOption: options.setRuntimeOption, + cycleRuntimeOption: options.cycleRuntimeOption, + }; +} + export function registerIpcHandlersService(deps: IpcServiceDeps): void { ipcMain.on( "set-ignore-mouse-events", diff --git a/src/core/services/jimaku-runtime-service.ts b/src/core/services/jimaku-service.ts similarity index 100% rename from src/core/services/jimaku-runtime-service.ts rename to src/core/services/jimaku-service.ts diff --git a/src/core/services/mining-runtime-service.ts b/src/core/services/mining-service.ts similarity index 100% rename from src/core/services/mining-runtime-service.ts rename to src/core/services/mining-service.ts diff --git a/src/core/services/mpv-runtime-service.test.ts b/src/core/services/mpv-control-service.test.ts similarity index 98% rename from src/core/services/mpv-runtime-service.test.ts rename to src/core/services/mpv-control-service.test.ts index 64c3fab..5abf49e 100644 --- a/src/core/services/mpv-runtime-service.test.ts +++ b/src/core/services/mpv-control-service.test.ts @@ -6,7 +6,7 @@ import { sendMpvCommandRuntimeService, setMpvSubVisibilityRuntimeService, showMpvOsdRuntimeService, -} from "./mpv-runtime-service"; +} from "./mpv-control-service"; test("showMpvOsdRuntimeService sends show-text when connected", () => { const commands: (string | number)[][] = []; diff --git a/src/core/services/mpv-runtime-service.ts b/src/core/services/mpv-control-service.ts similarity index 100% rename from src/core/services/mpv-runtime-service.ts rename to src/core/services/mpv-control-service.ts diff --git a/src/core/services/numeric-shortcut-runtime-service.test.ts b/src/core/services/numeric-shortcut-runtime-service.test.ts deleted file mode 100644 index 8351ecf..0000000 --- a/src/core/services/numeric-shortcut-runtime-service.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createNumericShortcutRuntimeService } from "./numeric-shortcut-runtime-service"; - -test("createNumericShortcutRuntimeService creates sessions wired to globalShortcut", () => { - const registered: string[] = []; - const unregistered: string[] = []; - const osd: string[] = []; - const handlers = new Map void>(); - - const runtime = createNumericShortcutRuntimeService({ - globalShortcut: { - register: (accelerator, callback) => { - registered.push(accelerator); - handlers.set(accelerator, callback); - return true; - }, - unregister: (accelerator) => { - unregistered.push(accelerator); - handlers.delete(accelerator); - }, - }, - showMpvOsd: (text) => { - osd.push(text); - }, - setTimer: () => setTimeout(() => {}, 1000), - clearTimer: (timer) => clearTimeout(timer), - }); - - const session = runtime.createSession(); - session.start({ - timeoutMs: 5000, - onDigit: () => {}, - messages: { - prompt: "Select count", - timeout: "Timed out", - }, - }); - - assert.equal(session.isActive(), true); - assert.ok(registered.includes("1")); - assert.ok(registered.includes("Escape")); - assert.equal(osd[0], "Select count"); - - handlers.get("Escape")?.(); - assert.equal(session.isActive(), false); - assert.ok(unregistered.includes("Escape")); -}); diff --git a/src/core/services/numeric-shortcut-runtime-service.ts b/src/core/services/numeric-shortcut-runtime-service.ts deleted file mode 100644 index 48140fd..0000000 --- a/src/core/services/numeric-shortcut-runtime-service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - createNumericShortcutSessionService, -} from "./numeric-shortcut-session-service"; - -interface GlobalShortcutLike { - register: (accelerator: string, callback: () => void) => boolean; - unregister: (accelerator: string) => void; -} - -export interface NumericShortcutRuntimeOptions { - globalShortcut: GlobalShortcutLike; - showMpvOsd: (text: string) => void; - setTimer: ( - handler: () => void, - timeoutMs: number, - ) => ReturnType; - clearTimer: (timer: ReturnType) => void; -} - -export function createNumericShortcutRuntimeService( - options: NumericShortcutRuntimeOptions, -) { - const createSession = () => - createNumericShortcutSessionService({ - registerShortcut: (accelerator, handler) => - options.globalShortcut.register(accelerator, handler), - unregisterShortcut: (accelerator) => - options.globalShortcut.unregister(accelerator), - setTimer: options.setTimer, - clearTimer: options.clearTimer, - showMpvOsd: options.showMpvOsd, - }); - - return { - createSession, - }; -} diff --git a/src/core/services/numeric-shortcut-session-service.ts b/src/core/services/numeric-shortcut-service.ts similarity index 70% rename from src/core/services/numeric-shortcut-session-service.ts rename to src/core/services/numeric-shortcut-service.ts index 7b4d94d..c6fd1ea 100644 --- a/src/core/services/numeric-shortcut-session-service.ts +++ b/src/core/services/numeric-shortcut-service.ts @@ -1,3 +1,37 @@ +interface GlobalShortcutLike { + register: (accelerator: string, callback: () => void) => boolean; + unregister: (accelerator: string) => void; +} + +export interface NumericShortcutRuntimeOptions { + globalShortcut: GlobalShortcutLike; + showMpvOsd: (text: string) => void; + setTimer: ( + handler: () => void, + timeoutMs: number, + ) => ReturnType; + clearTimer: (timer: ReturnType) => void; +} + +export function createNumericShortcutRuntimeService( + options: NumericShortcutRuntimeOptions, +) { + const createSession = () => + createNumericShortcutSessionService({ + registerShortcut: (accelerator, handler) => + options.globalShortcut.register(accelerator, handler), + unregisterShortcut: (accelerator) => + options.globalShortcut.unregister(accelerator), + setTimer: options.setTimer, + clearTimer: options.clearTimer, + showMpvOsd: options.showMpvOsd, + }); + + return { + createSession, + }; +} + export interface NumericShortcutSessionMessages { prompt: string; timeout: string; diff --git a/src/core/services/numeric-shortcut-session-service.test.ts b/src/core/services/numeric-shortcut-session-service.test.ts index 4002a37..c5d699a 100644 --- a/src/core/services/numeric-shortcut-session-service.test.ts +++ b/src/core/services/numeric-shortcut-session-service.test.ts @@ -1,6 +1,54 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { createNumericShortcutSessionService } from "./numeric-shortcut-session-service"; +import { + createNumericShortcutRuntimeService, + createNumericShortcutSessionService, +} from "./numeric-shortcut-service"; + +test("createNumericShortcutRuntimeService creates sessions wired to globalShortcut", () => { + const registered: string[] = []; + const unregistered: string[] = []; + const osd: string[] = []; + const handlers = new Map void>(); + + const runtime = createNumericShortcutRuntimeService({ + globalShortcut: { + register: (accelerator, callback) => { + registered.push(accelerator); + handlers.set(accelerator, callback); + return true; + }, + unregister: (accelerator) => { + unregistered.push(accelerator); + handlers.delete(accelerator); + }, + }, + showMpvOsd: (text) => { + osd.push(text); + }, + setTimer: () => setTimeout(() => {}, 1000), + clearTimer: (timer) => clearTimeout(timer), + }); + + const session = runtime.createSession(); + session.start({ + timeoutMs: 5000, + onDigit: () => {}, + messages: { + prompt: "Select count", + timeout: "Timed out", + }, + }); + + assert.equal(session.isActive(), true); + assert.ok(registered.includes("1")); + assert.ok(registered.includes("Escape")); + assert.equal(osd[0], "Select count"); + + handlers.get("Escape")?.(); + assert.equal(session.isActive(), false); + assert.ok(unregistered.includes("Escape")); +}); test("numeric shortcut session handles digit selection and unregisters shortcuts", () => { const handlers = new Map void>(); diff --git a/src/core/services/overlay-bridge-runtime-service.test.ts b/src/core/services/overlay-bridge-service.test.ts similarity index 98% rename from src/core/services/overlay-bridge-runtime-service.test.ts rename to src/core/services/overlay-bridge-service.test.ts index a9284bd..fafe405 100644 --- a/src/core/services/overlay-bridge-runtime-service.test.ts +++ b/src/core/services/overlay-bridge-service.test.ts @@ -4,7 +4,7 @@ import { KikuFieldGroupingChoice } from "../../types"; import { createFieldGroupingCallbackRuntimeService, sendToVisibleOverlayRuntimeService, -} from "./overlay-bridge-runtime-service"; +} from "./overlay-bridge-service"; test("sendToVisibleOverlayRuntimeService restores visibility flag when opening hidden overlay modal", () => { const sent: unknown[][] = []; diff --git a/src/core/services/overlay-bridge-runtime-service.ts b/src/core/services/overlay-bridge-service.ts similarity index 73% rename from src/core/services/overlay-bridge-runtime-service.ts rename to src/core/services/overlay-bridge-service.ts index 9e2d8eb..a705921 100644 --- a/src/core/services/overlay-bridge-runtime-service.ts +++ b/src/core/services/overlay-bridge-service.ts @@ -2,8 +2,6 @@ import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData, } from "../../types"; -import { addOverlayModalRestoreFlagService } from "./overlay-modal-restore-service"; -import { sendToVisibleOverlayService } from "./overlay-send-service"; import { createFieldGroupingCallbackService } from "./field-grouping-service"; import { BrowserWindow } from "electron"; @@ -16,19 +14,20 @@ export function sendToVisibleOverlayRuntimeService(options: { restoreOnModalClose?: T; restoreVisibleOverlayOnModalClose: Set; }): boolean { - return sendToVisibleOverlayService({ - mainWindow: options.mainWindow, - visibleOverlayVisible: options.visibleOverlayVisible, - setVisibleOverlayVisible: options.setVisibleOverlayVisible, - channel: options.channel, - payload: options.payload, - restoreOnModalClose: options.restoreOnModalClose, - addRestoreFlag: (modal) => - addOverlayModalRestoreFlagService( - options.restoreVisibleOverlayOnModalClose, - modal as T, - ), - }); + if (!options.mainWindow || options.mainWindow.isDestroyed()) return false; + const wasVisible = options.visibleOverlayVisible; + if (!options.visibleOverlayVisible) { + options.setVisibleOverlayVisible(true); + } + if (!wasVisible && options.restoreOnModalClose) { + options.restoreVisibleOverlayOnModalClose.add(options.restoreOnModalClose); + } + if (options.payload === undefined) { + options.mainWindow.webContents.send(options.channel); + } else { + options.mainWindow.webContents.send(options.channel, options.payload); + } + return true; } export function createFieldGroupingCallbackRuntimeService( diff --git a/src/core/services/overlay-broadcast-runtime-service.test.ts b/src/core/services/overlay-broadcast-runtime-service.test.ts deleted file mode 100644 index 08995d0..0000000 --- a/src/core/services/overlay-broadcast-runtime-service.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - broadcastRuntimeOptionsChangedRuntimeService, - broadcastToOverlayWindowsRuntimeService, - getOverlayWindowsRuntimeService, - setOverlayDebugVisualizationEnabledRuntimeService, -} from "./overlay-broadcast-runtime-service"; - -test("getOverlayWindowsRuntimeService returns non-destroyed windows only", () => { - const alive = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; - const dead = { isDestroyed: () => true } as unknown as Electron.BrowserWindow; - const windows = getOverlayWindowsRuntimeService({ - mainWindow: alive, - invisibleWindow: dead, - }); - assert.deepEqual(windows, [alive]); -}); - -test("broadcastToOverlayWindowsRuntimeService sends channel to each window", () => { - const calls: unknown[][] = []; - const window = { - webContents: { - send: (...args: unknown[]) => { - calls.push(args); - }, - }, - } as unknown as Electron.BrowserWindow; - broadcastToOverlayWindowsRuntimeService([window], "x", 1, "a"); - assert.deepEqual(calls, [["x", 1, "a"]]); -}); - -test("runtime-option and debug broadcasts use expected channels", () => { - const broadcasts: unknown[][] = []; - broadcastRuntimeOptionsChangedRuntimeService( - () => [], - (channel, ...args) => { - broadcasts.push([channel, ...args]); - }, - ); - let state = false; - const changed = setOverlayDebugVisualizationEnabledRuntimeService( - state, - true, - (enabled) => { - state = enabled; - }, - (channel, ...args) => { - broadcasts.push([channel, ...args]); - }, - ); - assert.equal(changed, true); - assert.equal(state, true); - assert.deepEqual(broadcasts, [ - ["runtime-options:changed", []], - ["overlay-debug-visualization:set", true], - ]); -}); diff --git a/src/core/services/overlay-broadcast-runtime-service.ts b/src/core/services/overlay-broadcast-runtime-service.ts deleted file mode 100644 index 6c44506..0000000 --- a/src/core/services/overlay-broadcast-runtime-service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { BrowserWindow } from "electron"; -import { RuntimeOptionState } from "../../types"; - -export function getOverlayWindowsRuntimeService(options: { - mainWindow: BrowserWindow | null; - invisibleWindow: BrowserWindow | null; -}): BrowserWindow[] { - const windows: BrowserWindow[] = []; - if (options.mainWindow && !options.mainWindow.isDestroyed()) { - windows.push(options.mainWindow); - } - if (options.invisibleWindow && !options.invisibleWindow.isDestroyed()) { - windows.push(options.invisibleWindow); - } - return windows; -} - -export function broadcastToOverlayWindowsRuntimeService( - windows: BrowserWindow[], - channel: string, - ...args: unknown[] -): void { - for (const window of windows) { - window.webContents.send(channel, ...args); - } -} - -export function broadcastRuntimeOptionsChangedRuntimeService( - getRuntimeOptionsState: () => RuntimeOptionState[], - broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, -): void { - broadcastToOverlayWindows("runtime-options:changed", getRuntimeOptionsState()); -} - -export function setOverlayDebugVisualizationEnabledRuntimeService( - currentEnabled: boolean, - nextEnabled: boolean, - setState: (enabled: boolean) => void, - broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, -): boolean { - if (currentEnabled === nextEnabled) return false; - setState(nextEnabled); - broadcastToOverlayWindows("overlay-debug-visualization:set", nextEnabled); - return true; -} diff --git a/src/core/services/overlay-manager-service.test.ts b/src/core/services/overlay-manager-service.test.ts index 57565d4..1b92694 100644 --- a/src/core/services/overlay-manager-service.test.ts +++ b/src/core/services/overlay-manager-service.test.ts @@ -1,6 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { createOverlayManagerService } from "./overlay-manager-service"; +import { + broadcastRuntimeOptionsChangedRuntimeService, + createOverlayManagerService, + setOverlayDebugVisualizationEnabledRuntimeService, +} from "./overlay-manager-service"; test("overlay manager initializes with empty windows and hidden overlays", () => { const manager = createOverlayManagerService(); @@ -40,3 +44,55 @@ test("overlay manager stores visibility state", () => { assert.equal(manager.getVisibleOverlayVisible(), true); assert.equal(manager.getInvisibleOverlayVisible(), true); }); + +test("overlay manager broadcasts to non-destroyed windows", () => { + const manager = createOverlayManagerService(); + const calls: unknown[][] = []; + const aliveWindow = { + isDestroyed: () => false, + webContents: { + send: (...args: unknown[]) => { + calls.push(args); + }, + }, + } as unknown as Electron.BrowserWindow; + const deadWindow = { + isDestroyed: () => true, + webContents: { + send: (..._args: unknown[]) => {}, + }, + } as unknown as Electron.BrowserWindow; + + manager.setMainWindow(aliveWindow); + manager.setInvisibleWindow(deadWindow); + manager.broadcastToOverlayWindows("x", 1, "a"); + + assert.deepEqual(calls, [["x", 1, "a"]]); +}); + +test("runtime-option and debug broadcasts use expected channels", () => { + const broadcasts: unknown[][] = []; + broadcastRuntimeOptionsChangedRuntimeService( + () => [], + (channel, ...args) => { + broadcasts.push([channel, ...args]); + }, + ); + let state = false; + const changed = setOverlayDebugVisualizationEnabledRuntimeService( + state, + true, + (enabled) => { + state = enabled; + }, + (channel, ...args) => { + broadcasts.push([channel, ...args]); + }, + ); + assert.equal(changed, true); + assert.equal(state, true); + assert.deepEqual(broadcasts, [ + ["runtime-options:changed", []], + ["overlay-debug-visualization:set", true], + ]); +}); diff --git a/src/core/services/overlay-manager-service.ts b/src/core/services/overlay-manager-service.ts index 05344da..401cde3 100644 --- a/src/core/services/overlay-manager-service.ts +++ b/src/core/services/overlay-manager-service.ts @@ -1,4 +1,5 @@ import { BrowserWindow } from "electron"; +import { RuntimeOptionState } from "../../types"; export interface OverlayManagerService { getMainWindow: () => BrowserWindow | null; @@ -10,6 +11,7 @@ export interface OverlayManagerService { getInvisibleOverlayVisible: () => boolean; setInvisibleOverlayVisible: (visible: boolean) => void; getOverlayWindows: () => BrowserWindow[]; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; } export function createOverlayManagerService(): OverlayManagerService { @@ -45,5 +47,36 @@ export function createOverlayManagerService(): OverlayManagerService { } return windows; }, + broadcastToOverlayWindows: (channel, ...args) => { + const windows: BrowserWindow[] = []; + if (mainWindow && !mainWindow.isDestroyed()) { + windows.push(mainWindow); + } + if (invisibleWindow && !invisibleWindow.isDestroyed()) { + windows.push(invisibleWindow); + } + for (const window of windows) { + window.webContents.send(channel, ...args); + } + }, }; } + +export function broadcastRuntimeOptionsChangedRuntimeService( + getRuntimeOptionsState: () => RuntimeOptionState[], + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, +): void { + broadcastToOverlayWindows("runtime-options:changed", getRuntimeOptionsState()); +} + +export function setOverlayDebugVisualizationEnabledRuntimeService( + currentEnabled: boolean, + nextEnabled: boolean, + setState: (enabled: boolean) => void, + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, +): boolean { + if (currentEnabled === nextEnabled) return false; + setState(nextEnabled); + broadcastToOverlayWindows("overlay-debug-visualization:set", nextEnabled); + return true; +} diff --git a/src/core/services/overlay-modal-restore-service.test.ts b/src/core/services/overlay-modal-restore-service.test.ts deleted file mode 100644 index 89762ba..0000000 --- a/src/core/services/overlay-modal-restore-service.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - addOverlayModalRestoreFlagService, - handleOverlayModalClosedService, -} from "./overlay-modal-restore-service"; - -test("overlay modal restore service adds modal restore flag", () => { - const restore = new Set<"runtime-options" | "subsync">(); - addOverlayModalRestoreFlagService(restore, "runtime-options"); - assert.equal(restore.has("runtime-options"), true); -}); - -test("overlay modal restore service hides overlay only when last modal closes", () => { - const restore = new Set<"runtime-options" | "subsync">(); - const visibility: boolean[] = []; - - addOverlayModalRestoreFlagService(restore, "runtime-options"); - addOverlayModalRestoreFlagService(restore, "subsync"); - - handleOverlayModalClosedService(restore, "runtime-options", (visible) => { - visibility.push(visible); - }); - assert.equal(visibility.length, 0); - - handleOverlayModalClosedService(restore, "subsync", (visible) => { - visibility.push(visible); - }); - assert.deepEqual(visibility, [false]); -}); diff --git a/src/core/services/overlay-modal-restore-service.ts b/src/core/services/overlay-modal-restore-service.ts deleted file mode 100644 index 1630ce9..0000000 --- a/src/core/services/overlay-modal-restore-service.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function addOverlayModalRestoreFlagService( - restoreSet: Set, - modal: T, -): void { - restoreSet.add(modal); -} - -export function handleOverlayModalClosedService( - restoreSet: Set, - modal: T, - setVisibleOverlayVisible: (visible: boolean) => void, -): void { - if (!restoreSet.has(modal)) return; - restoreSet.delete(modal); - if (restoreSet.size === 0) { - setVisibleOverlayVisible(false); - } -} diff --git a/src/core/services/overlay-send-service.ts b/src/core/services/overlay-send-service.ts deleted file mode 100644 index 4bc75d9..0000000 --- a/src/core/services/overlay-send-service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { BrowserWindow } from "electron"; - -export function sendToVisibleOverlayService(options: { - mainWindow: BrowserWindow | null; - visibleOverlayVisible: boolean; - setVisibleOverlayVisible: (visible: boolean) => void; - channel: string; - payload?: unknown; - restoreOnModalClose?: string; - addRestoreFlag: (modal: string) => void; -}): boolean { - if (!options.mainWindow || options.mainWindow.isDestroyed()) return false; - const wasVisible = options.visibleOverlayVisible; - if (!options.visibleOverlayVisible) { - options.setVisibleOverlayVisible(true); - } - if (!wasVisible && options.restoreOnModalClose) { - options.addRestoreFlag(options.restoreOnModalClose); - } - if (options.payload === undefined) { - options.mainWindow.webContents.send(options.channel); - } else { - options.mainWindow.webContents.send(options.channel, options.payload); - } - return true; -} diff --git a/src/core/services/overlay-shortcut-fallback-runner.ts b/src/core/services/overlay-shortcut-fallback-runner.ts deleted file mode 100644 index 859bbf0..0000000 --- a/src/core/services/overlay-shortcut-fallback-runner.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ConfiguredShortcuts } from "../utils/shortcut-config"; - -export interface OverlayShortcutFallbackHandlers { - openRuntimeOptions: () => void; - openJimaku: () => void; - markAudioCard: () => void; - copySubtitleMultiple: (timeoutMs: number) => void; - copySubtitle: () => void; - toggleSecondarySub: () => void; - updateLastCardFromClipboard: () => void; - triggerFieldGrouping: () => void; - triggerSubsync: () => void; - mineSentence: () => void; - mineSentenceMultiple: (timeoutMs: number) => void; -} - -export function runOverlayShortcutLocalFallback( - input: Electron.Input, - shortcuts: ConfiguredShortcuts, - matcher: ( - input: Electron.Input, - accelerator: string, - allowWhenRegistered?: boolean, - ) => boolean, - handlers: OverlayShortcutFallbackHandlers, -): boolean { - const actions: Array<{ - accelerator: string | null | undefined; - run: () => void; - allowWhenRegistered?: boolean; - }> = [ - { - accelerator: shortcuts.openRuntimeOptions, - run: () => { - handlers.openRuntimeOptions(); - }, - }, - { - accelerator: shortcuts.openJimaku, - run: () => { - handlers.openJimaku(); - }, - }, - { - accelerator: shortcuts.markAudioCard, - run: () => { - handlers.markAudioCard(); - }, - }, - { - accelerator: shortcuts.copySubtitleMultiple, - run: () => { - handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs); - }, - }, - { - accelerator: shortcuts.copySubtitle, - run: () => { - handlers.copySubtitle(); - }, - }, - { - accelerator: shortcuts.toggleSecondarySub, - run: () => handlers.toggleSecondarySub(), - allowWhenRegistered: true, - }, - { - accelerator: shortcuts.updateLastCardFromClipboard, - run: () => { - handlers.updateLastCardFromClipboard(); - }, - }, - { - accelerator: shortcuts.triggerFieldGrouping, - run: () => { - handlers.triggerFieldGrouping(); - }, - }, - { - accelerator: shortcuts.triggerSubsync, - run: () => { - handlers.triggerSubsync(); - }, - }, - { - accelerator: shortcuts.mineSentence, - run: () => { - handlers.mineSentence(); - }, - }, - { - accelerator: shortcuts.mineSentenceMultiple, - run: () => { - handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs); - }, - }, - ]; - - for (const action of actions) { - if (!action.accelerator) continue; - if ( - matcher( - input, - action.accelerator, - action.allowWhenRegistered === true, - ) - ) { - action.run(); - return true; - } - } - - return false; -} diff --git a/src/core/services/overlay-shortcut-runtime-service.ts b/src/core/services/overlay-shortcut-handler.ts similarity index 53% rename from src/core/services/overlay-shortcut-runtime-service.ts rename to src/core/services/overlay-shortcut-handler.ts index 3a241f9..de3e740 100644 --- a/src/core/services/overlay-shortcut-runtime-service.ts +++ b/src/core/services/overlay-shortcut-handler.ts @@ -1,8 +1,20 @@ -import { - OverlayShortcutFallbackHandlers, -} from "./overlay-shortcut-fallback-runner"; +import { ConfiguredShortcuts } from "../utils/shortcut-config"; import { OverlayShortcutHandlers } from "./overlay-shortcut-service"; +export interface OverlayShortcutFallbackHandlers { + openRuntimeOptions: () => void; + openJimaku: () => void; + markAudioCard: () => void; + copySubtitleMultiple: (timeoutMs: number) => void; + copySubtitle: () => void; + toggleSecondarySub: () => void; + updateLastCardFromClipboard: () => void; + triggerFieldGrouping: () => void; + triggerSubsync: () => void; + mineSentence: () => void; + mineSentenceMultiple: (timeoutMs: number) => void; +} + export interface OverlayShortcutRuntimeDeps { showMpvOsd: (text: string) => void; openRuntimeOptions: () => void; @@ -103,3 +115,102 @@ export function createOverlayShortcutRuntimeHandlers( return { overlayHandlers, fallbackHandlers }; } + +export function runOverlayShortcutLocalFallback( + input: Electron.Input, + shortcuts: ConfiguredShortcuts, + matcher: ( + input: Electron.Input, + accelerator: string, + allowWhenRegistered?: boolean, + ) => boolean, + handlers: OverlayShortcutFallbackHandlers, +): boolean { + const actions: Array<{ + accelerator: string | null | undefined; + run: () => void; + allowWhenRegistered?: boolean; + }> = [ + { + accelerator: shortcuts.openRuntimeOptions, + run: () => { + handlers.openRuntimeOptions(); + }, + }, + { + accelerator: shortcuts.openJimaku, + run: () => { + handlers.openJimaku(); + }, + }, + { + accelerator: shortcuts.markAudioCard, + run: () => { + handlers.markAudioCard(); + }, + }, + { + accelerator: shortcuts.copySubtitleMultiple, + run: () => { + handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs); + }, + }, + { + accelerator: shortcuts.copySubtitle, + run: () => { + handlers.copySubtitle(); + }, + }, + { + accelerator: shortcuts.toggleSecondarySub, + run: () => handlers.toggleSecondarySub(), + allowWhenRegistered: true, + }, + { + accelerator: shortcuts.updateLastCardFromClipboard, + run: () => { + handlers.updateLastCardFromClipboard(); + }, + }, + { + accelerator: shortcuts.triggerFieldGrouping, + run: () => { + handlers.triggerFieldGrouping(); + }, + }, + { + accelerator: shortcuts.triggerSubsync, + run: () => { + handlers.triggerSubsync(); + }, + }, + { + accelerator: shortcuts.mineSentence, + run: () => { + handlers.mineSentence(); + }, + }, + { + accelerator: shortcuts.mineSentenceMultiple, + run: () => { + handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs); + }, + }, + ]; + + for (const action of actions) { + if (!action.accelerator) continue; + if ( + matcher( + input, + action.accelerator, + action.allowWhenRegistered === true, + ) + ) { + action.run(); + return true; + } + } + + return false; +} diff --git a/src/core/services/overlay-shortcut-lifecycle-service.ts b/src/core/services/overlay-shortcut-lifecycle-service.ts deleted file mode 100644 index 2ce0514..0000000 --- a/src/core/services/overlay-shortcut-lifecycle-service.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { OverlayShortcutHandlers, registerOverlayShortcutsService, unregisterOverlayShortcutsService } from "./overlay-shortcut-service"; -import { ConfiguredShortcuts } from "../utils/shortcut-config"; - -export interface OverlayShortcutLifecycleDeps { - getConfiguredShortcuts: () => ConfiguredShortcuts; - getOverlayHandlers: () => OverlayShortcutHandlers; - cancelPendingMultiCopy: () => void; - cancelPendingMineSentenceMultiple: () => void; -} - -export function registerOverlayShortcutsRuntimeService( - deps: OverlayShortcutLifecycleDeps, -): boolean { - return registerOverlayShortcutsService( - deps.getConfiguredShortcuts(), - deps.getOverlayHandlers(), - ); -} - -export function unregisterOverlayShortcutsRuntimeService( - shortcutsRegistered: boolean, - deps: OverlayShortcutLifecycleDeps, -): boolean { - if (!shortcutsRegistered) return shortcutsRegistered; - deps.cancelPendingMultiCopy(); - deps.cancelPendingMineSentenceMultiple(); - unregisterOverlayShortcutsService(deps.getConfiguredShortcuts()); - return false; -} - -export function syncOverlayShortcutsRuntimeService( - shouldBeActive: boolean, - shortcutsRegistered: boolean, - deps: OverlayShortcutLifecycleDeps, -): boolean { - if (shouldBeActive) { - return registerOverlayShortcutsRuntimeService(deps); - } - return unregisterOverlayShortcutsRuntimeService(shortcutsRegistered, deps); -} - -export function refreshOverlayShortcutsRuntimeService( - shouldBeActive: boolean, - shortcutsRegistered: boolean, - deps: OverlayShortcutLifecycleDeps, -): boolean { - const cleared = unregisterOverlayShortcutsRuntimeService( - shortcutsRegistered, - deps, - ); - return syncOverlayShortcutsRuntimeService(shouldBeActive, cleared, deps); -} diff --git a/src/core/services/overlay-shortcut-service.ts b/src/core/services/overlay-shortcut-service.ts index 7bab1ce..71d955a 100644 --- a/src/core/services/overlay-shortcut-service.ts +++ b/src/core/services/overlay-shortcut-service.ts @@ -16,6 +16,13 @@ export interface OverlayShortcutHandlers { openJimaku: () => void; } +export interface OverlayShortcutLifecycleDeps { + getConfiguredShortcuts: () => ConfiguredShortcuts; + getOverlayHandlers: () => OverlayShortcutHandlers; + cancelPendingMultiCopy: () => void; + cancelPendingMineSentenceMultiple: () => void; +} + export function registerOverlayShortcutsService( shortcuts: ConfiguredShortcuts, handlers: OverlayShortcutHandlers, @@ -167,3 +174,46 @@ export function unregisterOverlayShortcutsService( globalShortcut.unregister(shortcuts.openJimaku); } } + +export function registerOverlayShortcutsRuntimeService( + deps: OverlayShortcutLifecycleDeps, +): boolean { + return registerOverlayShortcutsService( + deps.getConfiguredShortcuts(), + deps.getOverlayHandlers(), + ); +} + +export function unregisterOverlayShortcutsRuntimeService( + shortcutsRegistered: boolean, + deps: OverlayShortcutLifecycleDeps, +): boolean { + if (!shortcutsRegistered) return shortcutsRegistered; + deps.cancelPendingMultiCopy(); + deps.cancelPendingMineSentenceMultiple(); + unregisterOverlayShortcutsService(deps.getConfiguredShortcuts()); + return false; +} + +export function syncOverlayShortcutsRuntimeService( + shouldBeActive: boolean, + shortcutsRegistered: boolean, + deps: OverlayShortcutLifecycleDeps, +): boolean { + if (shouldBeActive) { + return registerOverlayShortcutsRuntimeService(deps); + } + return unregisterOverlayShortcutsRuntimeService(shortcutsRegistered, deps); +} + +export function refreshOverlayShortcutsRuntimeService( + shouldBeActive: boolean, + shortcutsRegistered: boolean, + deps: OverlayShortcutLifecycleDeps, +): boolean { + const cleared = unregisterOverlayShortcutsRuntimeService( + shortcutsRegistered, + deps, + ); + return syncOverlayShortcutsRuntimeService(shouldBeActive, cleared, deps); +} diff --git a/src/core/services/overlay-visibility-runtime-service.ts b/src/core/services/overlay-visibility-runtime-service.ts deleted file mode 100644 index 53eecbe..0000000 --- a/src/core/services/overlay-visibility-runtime-service.ts +++ /dev/null @@ -1,46 +0,0 @@ -export function syncInvisibleOverlayMousePassthroughService(options: { - hasInvisibleWindow: () => boolean; - setIgnoreMouseEvents: (ignore: boolean, extra?: { forward: boolean }) => void; - visibleOverlayVisible: boolean; - invisibleOverlayVisible: boolean; -}): void { - if (!options.hasInvisibleWindow()) return; - if (options.visibleOverlayVisible) { - options.setIgnoreMouseEvents(true, { forward: true }); - } else if (options.invisibleOverlayVisible) { - options.setIgnoreMouseEvents(false); - } -} - -export function setVisibleOverlayVisibleService(options: { - visible: boolean; - setVisibleOverlayVisibleState: (visible: boolean) => void; - updateVisibleOverlayVisibility: () => void; - updateInvisibleOverlayVisibility: () => void; - syncInvisibleOverlayMousePassthrough: () => void; - shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; - isMpvConnected: () => boolean; - setMpvSubVisibility: (visible: boolean) => void; -}): void { - options.setVisibleOverlayVisibleState(options.visible); - options.updateVisibleOverlayVisibility(); - options.updateInvisibleOverlayVisibility(); - options.syncInvisibleOverlayMousePassthrough(); - if ( - options.shouldBindVisibleOverlayToMpvSubVisibility() && - options.isMpvConnected() - ) { - options.setMpvSubVisibility(!options.visible); - } -} - -export function setInvisibleOverlayVisibleService(options: { - visible: boolean; - setInvisibleOverlayVisibleState: (visible: boolean) => void; - updateInvisibleOverlayVisibility: () => void; - syncInvisibleOverlayMousePassthrough: () => void; -}): void { - options.setInvisibleOverlayVisibleState(options.visible); - options.updateInvisibleOverlayVisibility(); - options.syncInvisibleOverlayMousePassthrough(); -} diff --git a/src/core/services/overlay-visibility-service.ts b/src/core/services/overlay-visibility-service.ts index 894410e..6fbc860 100644 --- a/src/core/services/overlay-visibility-service.ts +++ b/src/core/services/overlay-visibility-service.ts @@ -24,17 +24,11 @@ export function updateVisibleOverlayVisibilityService(args: { enforceOverlayLayerOrder: () => void; syncOverlayShortcuts: () => void; }): void { - console.log( - "updateVisibleOverlayVisibility called, visibleOverlayVisible:", - args.visibleOverlayVisible, - ); if (!args.mainWindow || args.mainWindow.isDestroyed()) { - console.log("mainWindow not available"); return; } if (!args.visibleOverlayVisible) { - console.log("Hiding visible overlay"); args.mainWindow.hide(); if ( @@ -57,11 +51,6 @@ export function updateVisibleOverlayVisibilityService(args: { return; } - console.log( - "Should show visible overlay, isTracking:", - args.windowTracker?.isTracking(), - ); - if (args.shouldBindVisibleOverlayToMpvSubVisibility && args.mpvConnected) { args.mpvSend({ command: ["get_property", "secondary-sub-visibility"], @@ -72,11 +61,9 @@ export function updateVisibleOverlayVisibilityService(args: { if (args.windowTracker && args.windowTracker.isTracking()) { args.setTrackerNotReadyWarningShown(false); const geometry = args.windowTracker.getGeometry(); - console.log("Geometry:", geometry); if (geometry) { args.updateOverlayBounds(geometry); } - console.log("Showing visible overlay mainWindow"); args.ensureOverlayWindowLevel(args.mainWindow); args.mainWindow.show(); args.mainWindow.focus(); @@ -96,9 +83,6 @@ export function updateVisibleOverlayVisibilityService(args: { } if (!args.trackerNotReadyWarningShown) { - console.warn( - "Window tracker exists but is not tracking yet; using fallback bounds until tracking starts", - ); args.setTrackerNotReadyWarningShown(true); } const cursorPoint = screen.getCursorScreenPoint(); @@ -181,3 +165,50 @@ export function updateInvisibleOverlayVisibilityService(args: { showInvisibleWithoutFocus(); args.syncOverlayShortcuts(); } + +export function syncInvisibleOverlayMousePassthroughService(options: { + hasInvisibleWindow: () => boolean; + setIgnoreMouseEvents: (ignore: boolean, extra?: { forward: boolean }) => void; + visibleOverlayVisible: boolean; + invisibleOverlayVisible: boolean; +}): void { + if (!options.hasInvisibleWindow()) return; + if (options.visibleOverlayVisible) { + options.setIgnoreMouseEvents(true, { forward: true }); + } else if (options.invisibleOverlayVisible) { + options.setIgnoreMouseEvents(false); + } +} + +export function setVisibleOverlayVisibleService(options: { + visible: boolean; + setVisibleOverlayVisibleState: (visible: boolean) => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isMpvConnected: () => boolean; + setMpvSubVisibility: (visible: boolean) => void; +}): void { + options.setVisibleOverlayVisibleState(options.visible); + options.updateVisibleOverlayVisibility(); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); + if ( + options.shouldBindVisibleOverlayToMpvSubVisibility() && + options.isMpvConnected() + ) { + options.setMpvSubVisibility(!options.visible); + } +} + +export function setInvisibleOverlayVisibleService(options: { + visible: boolean; + setInvisibleOverlayVisibleState: (visible: boolean) => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; +}): void { + options.setInvisibleOverlayVisibleState(options.visible); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); +} diff --git a/src/core/services/runtime-options-runtime-service.test.ts b/src/core/services/runtime-options-ipc-service.test.ts similarity index 97% rename from src/core/services/runtime-options-runtime-service.test.ts rename to src/core/services/runtime-options-ipc-service.test.ts index cd0b742..5d53634 100644 --- a/src/core/services/runtime-options-runtime-service.test.ts +++ b/src/core/services/runtime-options-ipc-service.test.ts @@ -4,7 +4,7 @@ import { applyRuntimeOptionResultRuntimeService, cycleRuntimeOptionFromIpcRuntimeService, setRuntimeOptionFromIpcRuntimeService, -} from "./runtime-options-runtime-service"; +} from "./runtime-options-ipc-service"; test("applyRuntimeOptionResultRuntimeService emits success OSD message", () => { const osd: string[] = []; diff --git a/src/core/services/runtime-options-runtime-service.ts b/src/core/services/runtime-options-ipc-service.ts similarity index 100% rename from src/core/services/runtime-options-runtime-service.ts rename to src/core/services/runtime-options-ipc-service.ts diff --git a/src/core/services/runtime-options-manager-runtime-service.test.ts b/src/core/services/runtime-options-manager-runtime-service.test.ts deleted file mode 100644 index a49bd17..0000000 --- a/src/core/services/runtime-options-manager-runtime-service.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createRuntimeOptionsManagerRuntimeService } from "./runtime-options-manager-runtime-service"; - -test("createRuntimeOptionsManagerRuntimeService wires patch + options changed callbacks", () => { - const patches: unknown[] = []; - const changedSnapshots: unknown[] = []; - const manager = createRuntimeOptionsManagerRuntimeService({ - getAnkiConfig: () => ({ - behavior: { autoUpdateNewCards: true }, - isKiku: { fieldGrouping: "manual" }, - }), - applyAnkiPatch: (patch) => { - patches.push(patch); - }, - onOptionsChanged: (options) => { - changedSnapshots.push(options); - }, - }); - - const result = manager.setOptionValue("anki.autoUpdateNewCards", false); - assert.equal(result.ok, true); - assert.equal(patches.length > 0, true); - assert.equal(changedSnapshots.length > 0, true); -}); diff --git a/src/core/services/runtime-options-manager-runtime-service.ts b/src/core/services/runtime-options-manager-runtime-service.ts deleted file mode 100644 index 0f550f4..0000000 --- a/src/core/services/runtime-options-manager-runtime-service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { RuntimeOptionsManager } from "../../runtime-options"; -import { AnkiConnectConfig, RuntimeOptionState } from "../../types"; - -export interface RuntimeOptionsManagerRuntimeDeps { - getAnkiConfig: () => AnkiConnectConfig; - applyAnkiPatch: (patch: Partial) => void; - onOptionsChanged: (options: RuntimeOptionState[]) => void; -} - -export function createRuntimeOptionsManagerRuntimeService( - deps: RuntimeOptionsManagerRuntimeDeps, -): RuntimeOptionsManager { - return new RuntimeOptionsManager(deps.getAnkiConfig, { - applyAnkiPatch: deps.applyAnkiPatch, - onOptionsChanged: deps.onOptionsChanged, - }); -} diff --git a/src/core/services/shortcut-ui-deps-runtime-service.test.ts b/src/core/services/shortcut-ui-deps-runtime-service.test.ts deleted file mode 100644 index 1e68496..0000000 --- a/src/core/services/shortcut-ui-deps-runtime-service.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - runOverlayShortcutLocalFallbackRuntimeService, -} from "./shortcut-ui-deps-runtime-service"; - -function makeOptions() { - return { - getConfiguredShortcuts: () => ({ - toggleVisibleOverlayGlobal: null, - toggleInvisibleOverlayGlobal: null, - copySubtitle: null, - copySubtitleMultiple: null, - updateLastCardFromClipboard: null, - triggerFieldGrouping: null, - triggerSubsync: null, - mineSentence: null, - mineSentenceMultiple: null, - multiCopyTimeoutMs: 5000, - toggleSecondarySub: null, - markAudioCard: null, - openRuntimeOptions: "Ctrl+R", - openJimaku: null, - }), - getOverlayShortcutFallbackHandlers: () => ({ - openRuntimeOptions: () => {}, - openJimaku: () => {}, - markAudioCard: () => {}, - copySubtitleMultiple: () => {}, - copySubtitle: () => {}, - toggleSecondarySub: () => {}, - updateLastCardFromClipboard: () => {}, - triggerFieldGrouping: () => {}, - triggerSubsync: () => {}, - mineSentence: () => {}, - mineSentenceMultiple: () => {}, - }), - shortcutMatcher: () => false, - }; -} - -test("runOverlayShortcutLocalFallbackRuntimeService delegates and returns boolean", () => { - const options = { - ...makeOptions(), - shortcutMatcher: () => true, - }; - - const handled = runOverlayShortcutLocalFallbackRuntimeService( - { - key: "r", - code: "KeyR", - alt: false, - control: true, - shift: false, - meta: false, - type: "keyDown", - } as Electron.Input, - options, - ); - - assert.equal(handled, true); -}); diff --git a/src/core/services/shortcut-ui-deps-runtime-service.ts b/src/core/services/shortcut-ui-deps-runtime-service.ts deleted file mode 100644 index 74d2742..0000000 --- a/src/core/services/shortcut-ui-deps-runtime-service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ConfiguredShortcuts } from "../utils/shortcut-config"; -import { OverlayShortcutFallbackHandlers, runOverlayShortcutLocalFallback } from "./overlay-shortcut-fallback-runner"; - -export interface ShortcutUiRuntimeDepsOptions { - getConfiguredShortcuts: () => ConfiguredShortcuts; - getOverlayShortcutFallbackHandlers: () => OverlayShortcutFallbackHandlers; - shortcutMatcher: ( - input: Electron.Input, - accelerator: string, - allowWhenRegistered?: boolean, - ) => boolean; -} - -export function runOverlayShortcutLocalFallbackRuntimeService( - input: Electron.Input, - options: ShortcutUiRuntimeDepsOptions, -): boolean { - return runOverlayShortcutLocalFallback( - input, - options.getConfiguredShortcuts(), - options.shortcutMatcher, - options.getOverlayShortcutFallbackHandlers(), - ); -} diff --git a/src/core/services/startup-bootstrap-runtime-service.ts b/src/core/services/startup-bootstrap-runtime-service.ts deleted file mode 100644 index 6ea1f0d..0000000 --- a/src/core/services/startup-bootstrap-runtime-service.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { CliArgs } from "../../cli/args"; - -export interface StartupBootstrapRuntimeState { - initialArgs: CliArgs; - mpvSocketPath: string; - texthookerPort: number; - backendOverride: string | null; - autoStartOverlay: boolean; - texthookerOnlyMode: boolean; -} - -export interface StartupBootstrapRuntimeDeps { - argv: string[]; - parseArgs: (argv: string[]) => CliArgs; - setLogLevelEnv: (level: string) => void; - enableVerboseLogging: () => void; - forceX11Backend: (args: CliArgs) => void; - enforceUnsupportedWaylandMode: (args: CliArgs) => void; - getDefaultSocketPath: () => string; - defaultTexthookerPort: number; - runGenerateConfigFlow: (args: CliArgs) => boolean; - startAppLifecycle: (args: CliArgs) => void; -} - -export function runStartupBootstrapRuntimeService( - deps: StartupBootstrapRuntimeDeps, -): StartupBootstrapRuntimeState { - const initialArgs = deps.parseArgs(deps.argv); - - if (initialArgs.logLevel) { - deps.setLogLevelEnv(initialArgs.logLevel); - } else if (initialArgs.verbose) { - deps.enableVerboseLogging(); - } - - deps.forceX11Backend(initialArgs); - deps.enforceUnsupportedWaylandMode(initialArgs); - - const state: StartupBootstrapRuntimeState = { - initialArgs, - mpvSocketPath: initialArgs.socketPath ?? deps.getDefaultSocketPath(), - texthookerPort: initialArgs.texthookerPort ?? deps.defaultTexthookerPort, - backendOverride: initialArgs.backend ?? null, - autoStartOverlay: initialArgs.autoStartOverlay, - texthookerOnlyMode: initialArgs.texthooker, - }; - - if (!deps.runGenerateConfigFlow(initialArgs)) { - deps.startAppLifecycle(initialArgs); - } - - return state; -} diff --git a/src/core/services/startup-bootstrap-runtime-service.test.ts b/src/core/services/startup-bootstrap-service.test.ts similarity index 98% rename from src/core/services/startup-bootstrap-runtime-service.test.ts rename to src/core/services/startup-bootstrap-service.test.ts index 97b457b..cb8408e 100644 --- a/src/core/services/startup-bootstrap-runtime-service.test.ts +++ b/src/core/services/startup-bootstrap-service.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { runStartupBootstrapRuntimeService, -} from "./startup-bootstrap-runtime-service"; +} from "./startup-service"; import { CliArgs } from "../../cli/args"; function makeArgs(overrides: Partial = {}): CliArgs { diff --git a/src/core/services/startup-resource-runtime-service.test.ts b/src/core/services/startup-resource-runtime-service.test.ts deleted file mode 100644 index 9474155..0000000 --- a/src/core/services/startup-resource-runtime-service.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - createMecabTokenizerAndCheckRuntimeService, - createSubtitleTimingTrackerRuntimeService, -} from "./startup-resource-runtime-service"; - -test("createMecabTokenizerAndCheckRuntimeService sets tokenizer and checks availability", async () => { - const calls: string[] = []; - let assigned: unknown = null; - await createMecabTokenizerAndCheckRuntimeService({ - createMecabTokenizer: () => ({ - checkAvailability: async () => { - calls.push("checkAvailability"); - }, - }), - setMecabTokenizer: (tokenizer) => { - assigned = tokenizer; - calls.push("setMecabTokenizer"); - }, - }); - assert.equal(assigned !== null, true); - assert.deepEqual(calls, ["setMecabTokenizer", "checkAvailability"]); -}); - -test("createSubtitleTimingTrackerRuntimeService sets created tracker", () => { - const tracker = { id: "x" }; - let assigned: unknown = null; - createSubtitleTimingTrackerRuntimeService({ - createSubtitleTimingTracker: () => tracker, - setSubtitleTimingTracker: (value) => { - assigned = value; - }, - }); - assert.equal(assigned, tracker); -}); diff --git a/src/core/services/startup-resource-runtime-service.ts b/src/core/services/startup-resource-runtime-service.ts deleted file mode 100644 index f82c946..0000000 --- a/src/core/services/startup-resource-runtime-service.ts +++ /dev/null @@ -1,26 +0,0 @@ -interface MecabTokenizerLike { - checkAvailability: () => Promise; -} - -interface SubtitleTimingTrackerLike {} - -export async function createMecabTokenizerAndCheckRuntimeService< - T extends MecabTokenizerLike, ->(options: { - createMecabTokenizer: () => T; - setMecabTokenizer: (tokenizer: T) => void; -}): Promise { - const tokenizer = options.createMecabTokenizer(); - options.setMecabTokenizer(tokenizer); - await tokenizer.checkAvailability(); -} - -export function createSubtitleTimingTrackerRuntimeService< - T extends SubtitleTimingTrackerLike, ->(options: { - createSubtitleTimingTracker: () => T; - setSubtitleTimingTracker: (tracker: T) => void; -}): void { - const tracker = options.createSubtitleTimingTracker(); - options.setSubtitleTimingTracker(tracker); -} diff --git a/src/core/services/app-ready-runtime-service.ts b/src/core/services/startup-service.ts similarity index 58% rename from src/core/services/app-ready-runtime-service.ts rename to src/core/services/startup-service.ts index 99372cc..469aa49 100644 --- a/src/core/services/app-ready-runtime-service.ts +++ b/src/core/services/startup-service.ts @@ -1,5 +1,58 @@ +import { CliArgs } from "../../cli/args"; import { ConfigValidationWarning, SecondarySubMode } from "../../types"; +export interface StartupBootstrapRuntimeState { + initialArgs: CliArgs; + mpvSocketPath: string; + texthookerPort: number; + backendOverride: string | null; + autoStartOverlay: boolean; + texthookerOnlyMode: boolean; +} + +export interface StartupBootstrapRuntimeDeps { + argv: string[]; + parseArgs: (argv: string[]) => CliArgs; + setLogLevelEnv: (level: string) => void; + enableVerboseLogging: () => void; + forceX11Backend: (args: CliArgs) => void; + enforceUnsupportedWaylandMode: (args: CliArgs) => void; + getDefaultSocketPath: () => string; + defaultTexthookerPort: number; + runGenerateConfigFlow: (args: CliArgs) => boolean; + startAppLifecycle: (args: CliArgs) => void; +} + +export function runStartupBootstrapRuntimeService( + deps: StartupBootstrapRuntimeDeps, +): StartupBootstrapRuntimeState { + const initialArgs = deps.parseArgs(deps.argv); + + if (initialArgs.logLevel) { + deps.setLogLevelEnv(initialArgs.logLevel); + } else if (initialArgs.verbose) { + deps.enableVerboseLogging(); + } + + deps.forceX11Backend(initialArgs); + deps.enforceUnsupportedWaylandMode(initialArgs); + + const state: StartupBootstrapRuntimeState = { + initialArgs, + mpvSocketPath: initialArgs.socketPath ?? deps.getDefaultSocketPath(), + texthookerPort: initialArgs.texthookerPort ?? deps.defaultTexthookerPort, + backendOverride: initialArgs.backend ?? null, + autoStartOverlay: initialArgs.autoStartOverlay, + texthookerOnlyMode: initialArgs.texthooker, + }; + + if (!deps.runGenerateConfigFlow(initialArgs)) { + deps.startAppLifecycle(initialArgs); + } + + return state; +} + interface AppReadyConfigLike { secondarySub?: { defaultMode?: SecondarySubMode; @@ -55,7 +108,10 @@ export async function runAppReadyRuntimeService( const wsEnabled = wsConfig.enabled ?? "auto"; const wsPort = wsConfig.port || deps.defaultWebsocketPort; - if (wsEnabled === true || (wsEnabled === "auto" && !deps.hasMpvWebsocketPlugin())) { + if ( + wsEnabled === true || + (wsEnabled === "auto" && !deps.hasMpvWebsocketPlugin()) + ) { deps.startSubtitleWebsocket(wsPort); } else if (wsEnabled === "auto") { deps.log("mpv_websocket detected, skipping built-in WebSocket server"); diff --git a/src/core/services/subsync-runtime-service.ts b/src/core/services/subsync-runner-service.ts similarity index 100% rename from src/core/services/subsync-runtime-service.ts rename to src/core/services/subsync-runner-service.ts diff --git a/src/core/services/tokenizer-deps-runtime-service.test.ts b/src/core/services/tokenizer-deps-runtime-service.test.ts deleted file mode 100644 index 298fd62..0000000 --- a/src/core/services/tokenizer-deps-runtime-service.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { PartOfSpeech } from "../../types"; -import { createTokenizerDepsRuntimeService } from "./tokenizer-deps-runtime-service"; - -test("createTokenizerDepsRuntimeService tokenizes with mecab and merge", async () => { - let parserWindow: any = null; - let readyPromise: Promise | null = null; - let initPromise: Promise | null = null; - - const deps = createTokenizerDepsRuntimeService({ - getYomitanExt: () => null, - getYomitanParserWindow: () => parserWindow, - setYomitanParserWindow: (window) => { - parserWindow = window; - }, - getYomitanParserReadyPromise: () => readyPromise, - setYomitanParserReadyPromise: (promise) => { - readyPromise = promise; - }, - getYomitanParserInitPromise: () => initPromise, - setYomitanParserInitPromise: (promise) => { - initPromise = promise; - }, - getMecabTokenizer: () => ({ - tokenize: async () => [ - { - word: "猫", - partOfSpeech: PartOfSpeech.noun, - pos1: "名詞", - pos2: "一般", - pos3: "", - pos4: "", - inflectionType: "", - inflectionForm: "", - headword: "猫", - katakanaReading: "ネコ", - pronunciation: "ネコ", - }, - ], - }), - }); - - const merged = await deps.tokenizeWithMecab("猫"); - assert.ok(Array.isArray(merged)); - assert.equal(merged?.length, 1); - assert.equal(merged?.[0]?.surface, "猫"); -}); diff --git a/src/core/services/tokenizer-deps-runtime-service.ts b/src/core/services/tokenizer-deps-runtime-service.ts deleted file mode 100644 index 337a519..0000000 --- a/src/core/services/tokenizer-deps-runtime-service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { BrowserWindow, Extension } from "electron"; -import { mergeTokens } from "../../token-merger"; -import { TokenizerServiceDeps } from "./tokenizer-service"; - -interface RawTokenLike {} - -interface MecabTokenizerLike { - tokenize: (text: string) => Promise; -} - -export interface TokenizerDepsRuntimeOptions { - getYomitanExt: () => Extension | null; - getYomitanParserWindow: () => BrowserWindow | null; - setYomitanParserWindow: (window: BrowserWindow | null) => void; - getYomitanParserReadyPromise: () => Promise | null; - setYomitanParserReadyPromise: (promise: Promise | null) => void; - getYomitanParserInitPromise: () => Promise | null; - setYomitanParserInitPromise: (promise: Promise | null) => void; - getMecabTokenizer: () => MecabTokenizerLike | null; -} - -export function createTokenizerDepsRuntimeService( - options: TokenizerDepsRuntimeOptions, -): TokenizerServiceDeps { - return { - getYomitanExt: options.getYomitanExt, - getYomitanParserWindow: options.getYomitanParserWindow, - setYomitanParserWindow: options.setYomitanParserWindow, - getYomitanParserReadyPromise: options.getYomitanParserReadyPromise, - setYomitanParserReadyPromise: options.setYomitanParserReadyPromise, - getYomitanParserInitPromise: options.getYomitanParserInitPromise, - setYomitanParserInitPromise: options.setYomitanParserInitPromise, - tokenizeWithMecab: async (text) => { - const mecabTokenizer = options.getMecabTokenizer(); - if (!mecabTokenizer) { - return null; - } - const rawTokens = await mecabTokenizer.tokenize(text); - if (!rawTokens || rawTokens.length === 0) { - return null; - } - return mergeTokens(rawTokens as never); - }, - }; -} diff --git a/src/core/services/tokenizer-service.ts b/src/core/services/tokenizer-service.ts index 57d7c87..f3ddbf6 100644 --- a/src/core/services/tokenizer-service.ts +++ b/src/core/services/tokenizer-service.ts @@ -1,5 +1,6 @@ import { BrowserWindow, Extension, session } from "electron"; -import { MergedToken, PartOfSpeech, SubtitleData } from "../../types"; +import { mergeTokens } from "../../token-merger"; +import { MergedToken, PartOfSpeech, SubtitleData, Token } from "../../types"; interface YomitanParseHeadword { term?: unknown; @@ -28,6 +29,46 @@ export interface TokenizerServiceDeps { tokenizeWithMecab: (text: string) => Promise; } +interface MecabTokenizerLike { + tokenize: (text: string) => Promise; +} + +export interface TokenizerDepsRuntimeOptions { + getYomitanExt: () => Extension | null; + getYomitanParserWindow: () => BrowserWindow | null; + setYomitanParserWindow: (window: BrowserWindow | null) => void; + getYomitanParserReadyPromise: () => Promise | null; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + getYomitanParserInitPromise: () => Promise | null; + setYomitanParserInitPromise: (promise: Promise | null) => void; + getMecabTokenizer: () => MecabTokenizerLike | null; +} + +export function createTokenizerDepsRuntimeService( + options: TokenizerDepsRuntimeOptions, +): TokenizerServiceDeps { + return { + getYomitanExt: options.getYomitanExt, + getYomitanParserWindow: options.getYomitanParserWindow, + setYomitanParserWindow: options.setYomitanParserWindow, + getYomitanParserReadyPromise: options.getYomitanParserReadyPromise, + setYomitanParserReadyPromise: options.setYomitanParserReadyPromise, + getYomitanParserInitPromise: options.getYomitanParserInitPromise, + setYomitanParserInitPromise: options.setYomitanParserInitPromise, + tokenizeWithMecab: async (text) => { + const mecabTokenizer = options.getMecabTokenizer(); + if (!mecabTokenizer) { + return null; + } + const rawTokens = await mecabTokenizer.tokenize(text); + if (!rawTokens || rawTokens.length === 0) { + return null; + } + return mergeTokens(rawTokens); + }, + }; +} + function extractYomitanHeadword(segment: YomitanParseSegment): string { const headwords = segment.headwords; if (!Array.isArray(headwords) || headwords.length === 0) { diff --git a/src/main.ts b/src/main.ts index 7389b51..5c33d74 100644 --- a/src/main.ts +++ b/src/main.ts @@ -94,20 +94,15 @@ import { TexthookerService, applyMpvSubtitleRenderMetricsPatchService, broadcastRuntimeOptionsChangedRuntimeService, - broadcastToOverlayWindowsRuntimeService, copyCurrentSubtitleService, createAppLifecycleDepsRuntimeService, - createAppLoggingRuntimeService, createCliCommandDepsRuntimeService, createOverlayManagerService, createFieldGroupingOverlayRuntimeService, createIpcDepsRuntimeService, - createMecabTokenizerAndCheckRuntimeService, createNumericShortcutRuntimeService, createOverlayShortcutRuntimeHandlers, createOverlayWindowService, - createRuntimeOptionsManagerRuntimeService, - createSubtitleTimingTrackerRuntimeService, createTokenizerDepsRuntimeService, cycleSecondarySubModeService, enforceOverlayLayerOrderService, @@ -119,7 +114,6 @@ import { handleMineSentenceDigitService, handleMpvCommandFromIpcService, handleMultiCopyDigitService, - handleOverlayModalClosedService, hasMpvWebsocketPlugin, initializeOverlayRuntimeService, isAutoUpdateEnabledRuntimeService, @@ -137,8 +131,6 @@ import { registerOverlayShortcutsService, replayCurrentSubtitleRuntimeService, resolveJimakuApiKeyService, - runGenerateConfigFlowRuntimeService, - runOverlayShortcutLocalFallbackRuntimeService, runStartupBootstrapRuntimeService, runSubsyncManualFromIpcRuntimeService, saveSubtitlePositionService, @@ -164,13 +156,13 @@ import { updateOverlayBoundsService, updateVisibleOverlayVisibilityService, } from "./core/services"; -import { runAppReadyRuntimeService } from "./core/services/app-ready-runtime-service"; -import { runAppShutdownRuntimeService } from "./core/services/app-shutdown-runtime-service"; +import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-handler"; +import { runAppReadyRuntimeService } from "./core/services/startup-service"; import { applyRuntimeOptionResultRuntimeService, cycleRuntimeOptionFromIpcRuntimeService, setRuntimeOptionFromIpcRuntimeService, -} from "./core/services/runtime-options-runtime-service"; +} from "./core/services/runtime-options-ipc-service"; import { ConfigService, DEFAULT_CONFIG, @@ -225,7 +217,27 @@ const isDev = process.argv.includes("--dev") || process.argv.includes("--debug"); const texthookerService = new TexthookerService(); const subtitleWsService = new SubtitleWebSocketService(); -const appLogger = createAppLoggingRuntimeService(); +const appLogger = { + logInfo: (message: string) => { + console.log(message); + }, + logWarning: (message: string) => { + console.warn(message); + }, + logNoRunningInstance: () => { + console.error("No running instance. Use --start to launch the app."); + }, + logConfigWarning: (warning: { + path: string; + message: string; + value: unknown; + fallback: unknown; + }) => { + console.warn( + `[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`, + ); + }, +}; function getDefaultSocketPath(): string { if (process.platform === "win32") { @@ -292,22 +304,41 @@ let shortcutsRegistered = false; let overlayRuntimeInitialized = false; let fieldGroupingResolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; +let fieldGroupingResolverSequence = 0; let runtimeOptionsManager: RuntimeOptionsManager | null = null; let trackerNotReadyWarningShown = false; let overlayDebugVisualizationEnabled = false; const overlayManager = createOverlayManagerService(); type OverlayHostedModal = "runtime-options" | "subsync"; const restoreVisibleOverlayOnModalClose = new Set(); + +function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null { + return fieldGroupingResolver; +} + +function setFieldGroupingResolver( + resolver: ((choice: KikuFieldGroupingChoice) => void) | null, +): void { + if (!resolver) { + fieldGroupingResolver = null; + return; + } + const sequence = ++fieldGroupingResolverSequence; + const wrappedResolver = (choice: KikuFieldGroupingChoice): void => { + if (sequence !== fieldGroupingResolverSequence) return; + resolver(choice); + }; + fieldGroupingResolver = wrappedResolver; +} + const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntimeService({ getMainWindow: () => overlayManager.getMainWindow(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), - getResolver: () => fieldGroupingResolver, - setResolver: (resolver) => { - fieldGroupingResolver = resolver; - }, + getResolver: () => getFieldGroupingResolver(), + setResolver: (resolver) => setFieldGroupingResolver(resolver), getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, }); const sendToVisibleOverlay = fieldGroupingOverlayRuntime.sendToVisibleOverlay; @@ -323,7 +354,7 @@ function getOverlayWindows(): BrowserWindow[] { } function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { - broadcastToOverlayWindowsRuntimeService(getOverlayWindows(), channel, ...args); + overlayManager.broadcastToOverlayWindows(channel, ...args); } function broadcastRuntimeOptionsChanged(): void { @@ -459,25 +490,26 @@ const startupState = runStartupBootstrapRuntimeService({ }, getDefaultSocketPath: () => getDefaultSocketPath(), defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, - runGenerateConfigFlow: (args) => - runGenerateConfigFlowRuntimeService(args, { - shouldStartApp: (nextArgs) => shouldStartApp(nextArgs), - generateConfig: async (nextArgs) => - generateDefaultConfigFile(nextArgs, { - configDir: CONFIG_DIR, - defaultConfig: DEFAULT_CONFIG, - generateTemplate: (config) => generateConfigTemplate(config as never), - }), - onSuccess: (exitCode) => { + runGenerateConfigFlow: (args) => { + if (!args.generateConfig || shouldStartApp(args)) { + return false; + } + generateDefaultConfigFile(args, { + configDir: CONFIG_DIR, + defaultConfig: DEFAULT_CONFIG, + generateTemplate: (config) => generateConfigTemplate(config as never), + }) + .then((exitCode) => { process.exitCode = exitCode; app.quit(); - }, - onError: (error) => { + }) + .catch((error: Error) => { console.error(`Failed to generate config: ${error.message}`); process.exitCode = 1; app.quit(); - }, - }), + }); + return true; + }, startAppLifecycle: (args) => { startAppLifecycleService(args, createAppLifecycleDepsRuntimeService({ app, @@ -548,18 +580,20 @@ const startupState = runStartupBootstrapRuntimeService({ getConfigWarnings: () => configService.getWarnings(), logConfigWarning: (warning) => appLogger.logConfigWarning(warning), initRuntimeOptionsManager: () => { - runtimeOptionsManager = createRuntimeOptionsManagerRuntimeService({ - getAnkiConfig: () => configService.getConfig().ankiConnect, - applyAnkiPatch: (patch) => { - if (ankiIntegration) { - ankiIntegration.applyRuntimeConfigPatch(patch); - } + runtimeOptionsManager = new RuntimeOptionsManager( + () => configService.getConfig().ankiConnect, + { + applyAnkiPatch: (patch) => { + if (ankiIntegration) { + ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, + onOptionsChanged: () => { + broadcastRuntimeOptionsChanged(); + refreshOverlayShortcuts(); + }, }, - onOptionsChanged: () => { - broadcastRuntimeOptionsChanged(); - refreshOverlayShortcuts(); - }, - }); + ); }, setSecondarySubMode: (mode) => { secondarySubMode = mode; @@ -571,20 +605,15 @@ const startupState = runStartupBootstrapRuntimeService({ subtitleWsService.start(port, () => currentSubText); }, log: (message) => appLogger.logInfo(message), - createMecabTokenizerAndCheck: async () => - createMecabTokenizerAndCheckRuntimeService({ - createMecabTokenizer: () => new MecabTokenizer(), - setMecabTokenizer: (tokenizer) => { - mecabTokenizer = tokenizer; - }, - }), - createSubtitleTimingTracker: () => - createSubtitleTimingTrackerRuntimeService({ - createSubtitleTimingTracker: () => new SubtitleTimingTracker(), - setSubtitleTimingTracker: (tracker) => { - subtitleTimingTracker = tracker; - }, - }), + createMecabTokenizerAndCheck: async () => { + const tokenizer = new MecabTokenizer(); + mecabTokenizer = tokenizer; + await tokenizer.checkAvailability(); + }, + createSubtitleTimingTracker: () => { + const tracker = new SubtitleTimingTracker(); + subtitleTimingTracker = tracker; + }, loadYomitanExtension: async () => { await loadYomitanExtension(); }, @@ -596,52 +625,30 @@ const startupState = runStartupBootstrapRuntimeService({ }); }, onWillQuitCleanup: () => { - runAppShutdownRuntimeService({ - unregisterAllGlobalShortcuts: () => { - globalShortcut.unregisterAll(); - }, - stopSubtitleWebsocket: () => { - subtitleWsService.stop(); - }, - stopTexthookerService: () => { - texthookerService.stop(); - }, - destroyYomitanParserWindow: () => { - if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { - yomitanParserWindow.destroy(); - } - yomitanParserWindow = null; - }, - clearYomitanParserPromises: () => { - yomitanParserReadyPromise = null; - yomitanParserInitPromise = null; - }, - stopWindowTracker: () => { - if (windowTracker) { - windowTracker.stop(); - } - }, - destroyMpvSocket: () => { - if (mpvClient && mpvClient.socket) { - mpvClient.socket.destroy(); - } - }, - clearReconnectTimer: () => { - if (reconnectTimer) { - clearTimeout(reconnectTimer); - } - }, - destroySubtitleTimingTracker: () => { - if (subtitleTimingTracker) { - subtitleTimingTracker.destroy(); - } - }, - destroyAnkiIntegration: () => { - if (ankiIntegration) { - ankiIntegration.destroy(); - } - }, - }); + globalShortcut.unregisterAll(); + subtitleWsService.stop(); + texthookerService.stop(); + if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { + yomitanParserWindow.destroy(); + } + yomitanParserWindow = null; + yomitanParserReadyPromise = null; + yomitanParserInitPromise = null; + if (windowTracker) { + windowTracker.stop(); + } + if (mpvClient && mpvClient.socket) { + mpvClient.socket.destroy(); + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + if (subtitleTimingTracker) { + subtitleTimingTracker.destroy(); + } + if (ankiIntegration) { + ankiIntegration.destroy(); + } }, shouldRestoreWindowsOnActivate: () => overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, @@ -683,7 +690,9 @@ function handleCliCommand( }, shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, openInBrowser: (url) => { - shell.openExternal(url); + void shell.openExternal(url).catch((error) => { + console.error(`Failed to open browser for texthooker URL: ${url}`, error); + }); }, }, overlay: { @@ -898,15 +907,6 @@ function initializeOverlayRuntime(): void { overlayRuntimeInitialized = true; } -function getShortcutUiRuntimeDeps() { - return { - getConfiguredShortcuts: () => getConfiguredShortcuts(), - getOverlayShortcutFallbackHandlers: () => - getOverlayShortcutRuntimeHandlers().fallbackHandlers, - shortcutMatcher: shortcutMatchesInputForLocalFallback, - }; -} - function openYomitanSettings(): void { openYomitanSettingsWindow( { @@ -963,9 +963,11 @@ function getOverlayShortcutRuntimeHandlers() { } function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean { - return runOverlayShortcutLocalFallbackRuntimeService( + return runOverlayShortcutLocalFallback( input, - getShortcutUiRuntimeDeps(), + getConfiguredShortcuts(), + shortcutMatchesInputForLocalFallback, + getOverlayShortcutRuntimeHandlers().fallbackHandlers, ); } @@ -1275,11 +1277,11 @@ function toggleInvisibleOverlay(): void { function setOverlayVisible(visible: boolean): void { setVisibleOverlayVisible(visible); } function toggleOverlay(): void { toggleVisibleOverlay(); } function handleOverlayModalClosed(modal: OverlayHostedModal): void { - handleOverlayModalClosedService( - restoreVisibleOverlayOnModalClose, - modal, - (visible) => setVisibleOverlayVisible(visible), - ); + if (!restoreVisibleOverlayOnModalClose.has(modal)) return; + restoreVisibleOverlayOnModalClose.delete(modal); + if (restoreVisibleOverlayOnModalClose.size === 0) { + setVisibleOverlayVisible(false); + } } function handleMpvCommandFromIpc(command: (string | number)[]): void { @@ -1381,10 +1383,8 @@ registerAnkiJimakuIpcRuntimeService( showDesktopNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), - getFieldGroupingResolver: () => fieldGroupingResolver, - setFieldGroupingResolver: (resolver) => { - fieldGroupingResolver = resolver; - }, + getFieldGroupingResolver: () => getFieldGroupingResolver(), + setFieldGroupingResolver: (resolver) => setFieldGroupingResolver(resolver), parseMediaInfo: (mediaPath) => parseMediaInfo(mediaPath), getCurrentMediaPath: () => currentMediaPath, jimakuFetchJson: (endpoint, query) => jimakuFetchJson(endpoint, query),