refactor(core): consolidate services and remove runtime wrappers

This commit is contained in:
kyasuda
2026-02-10 13:13:47 -08:00
committed by sudacode
parent 5cc22e3f1b
commit f868fdbbb3
62 changed files with 954 additions and 1858 deletions

View File

@@ -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<string, (...args: unknown[]) => 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");
});

View File

@@ -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<void>;
}
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<void>;
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,
};
}

View File

@@ -20,6 +20,63 @@ export interface AppLifecycleServiceDeps {
restoreWindowsOnActivate: () => void;
}
interface AppLike {
requestSingleInstanceLock: () => boolean;
quit: () => void;
on: (...args: any[]) => unknown;
whenReady: () => Promise<void>;
}
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<void>;
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) => {
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)) {

View File

@@ -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 /);
});

View File

@@ -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, "log" | "warn" | "error"> = 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));
},
};
}

View File

@@ -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<AppReadyRuntimeDeps> = {}) {
const calls: string[] = [];

View File

@@ -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",
]);
});

View File

@@ -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();
}

View File

@@ -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);
});

View File

@@ -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<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
}
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,
};
}

View File

@@ -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<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
}
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<void>,
deps: CliCommandServiceDeps,

View File

@@ -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> = {}): 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);
});

View File

@@ -1,26 +0,0 @@
import { CliArgs } from "../../cli/args";
export interface ConfigGenerationRuntimeDeps {
shouldStartApp: (args: CliArgs) => boolean;
generateConfig: (args: CliArgs) => Promise<number>;
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;
}

View File

@@ -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 /);
});

View File

@@ -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));
}

View File

@@ -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[][] = [];

View File

@@ -5,7 +5,7 @@ import {
import {
createFieldGroupingCallbackRuntimeService,
sendToVisibleOverlayRuntimeService,
} from "./overlay-bridge-runtime-service";
} from "./overlay-bridge-service";
interface WindowLike {
isDestroyed: () => boolean;

View File

@@ -16,6 +16,16 @@ export function createFieldGroupingCallbackService(options: {
data: KikuFieldGroupingRequestData,
): Promise<KikuFieldGroupingChoice> => {
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;
if (options.getResolver() === finish) {
options.setResolver(null);
}
resolve(choice);
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {

View File

@@ -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";

View File

@@ -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(), "");
});

View File

@@ -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<unknown>;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: unknown) => void;
getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
runSubsyncManual: (request: unknown) => Promise<unknown>;
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,
};
}

View File

@@ -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<unknown>;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: unknown) => void;
getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
runSubsyncManual: (request: unknown) => Promise<unknown>;
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",

View File

@@ -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)[][] = [];

View File

@@ -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<string, () => 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"));
});

View File

@@ -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<typeof setTimeout>;
clearTimer: (timer: ReturnType<typeof setTimeout>) => 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,
};
}

View File

@@ -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<typeof setTimeout>;
clearTimer: (timer: ReturnType<typeof setTimeout>) => 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;

View File

@@ -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<string, () => 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<string, () => void>();

View File

@@ -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[][] = [];

View File

@@ -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<T extends string>(options: {
restoreOnModalClose?: T;
restoreVisibleOverlayOnModalClose: Set<T>;
}): 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<T extends string>(

View File

@@ -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],
]);
});

View File

@@ -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;
}

View File

@@ -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],
]);
});

View File

@@ -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;
}

View File

@@ -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]);
});

View File

@@ -1,18 +0,0 @@
export function addOverlayModalRestoreFlagService<T extends string>(
restoreSet: Set<T>,
modal: T,
): void {
restoreSet.add(modal);
}
export function handleOverlayModalClosedService<T extends string>(
restoreSet: Set<T>,
modal: T,
setVisibleOverlayVisible: (visible: boolean) => void,
): void {
if (!restoreSet.has(modal)) return;
restoreSet.delete(modal);
if (restoreSet.size === 0) {
setVisibleOverlayVisible(false);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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[] = [];

View File

@@ -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);
});

View File

@@ -1,17 +0,0 @@
import { RuntimeOptionsManager } from "../../runtime-options";
import { AnkiConnectConfig, RuntimeOptionState } from "../../types";
export interface RuntimeOptionsManagerRuntimeDeps {
getAnkiConfig: () => AnkiConnectConfig;
applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
onOptionsChanged: (options: RuntimeOptionState[]) => void;
}
export function createRuntimeOptionsManagerRuntimeService(
deps: RuntimeOptionsManagerRuntimeDeps,
): RuntimeOptionsManager {
return new RuntimeOptionsManager(deps.getAnkiConfig, {
applyAnkiPatch: deps.applyAnkiPatch,
onOptionsChanged: deps.onOptionsChanged,
});
}

View File

@@ -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);
});

View File

@@ -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(),
);
}

View File

@@ -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;
}

View File

@@ -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> = {}): CliArgs {

View File

@@ -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);
});

View File

@@ -1,26 +0,0 @@
interface MecabTokenizerLike {
checkAvailability: () => Promise<unknown>;
}
interface SubtitleTimingTrackerLike {}
export async function createMecabTokenizerAndCheckRuntimeService<
T extends MecabTokenizerLike,
>(options: {
createMecabTokenizer: () => T;
setMecabTokenizer: (tokenizer: T) => void;
}): Promise<void> {
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);
}

View File

@@ -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");

View File

@@ -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<void> | null = null;
let initPromise: Promise<boolean> | 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, "猫");
});

View File

@@ -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<RawTokenLike[] | null>;
}
export interface TokenizerDepsRuntimeOptions {
getYomitanExt: () => Extension | null;
getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | 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);
},
};
}

View File

@@ -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<MergedToken[] | null>;
}
interface MecabTokenizerLike {
tokenize: (text: string) => Promise<Token[] | null>;
}
export interface TokenizerDepsRuntimeOptions {
getYomitanExt: () => Extension | null;
getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | 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) {

View File

@@ -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<OverlayHostedModal>();
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<OverlayHostedModal>({
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, {
runGenerateConfigFlow: (args) => {
if (!args.generateConfig || shouldStartApp(args)) {
return false;
}
generateDefaultConfigFile(args, {
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
generateTemplate: (config) => generateConfigTemplate(config as never),
}),
onSuccess: (exitCode) => {
})
.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,8 +580,9 @@ const startupState = runStartupBootstrapRuntimeService({
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
initRuntimeOptionsManager: () => {
runtimeOptionsManager = createRuntimeOptionsManagerRuntimeService({
getAnkiConfig: () => configService.getConfig().ankiConnect,
runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (ankiIntegration) {
ankiIntegration.applyRuntimeConfigPatch(patch);
@@ -559,7 +592,8 @@ const startupState = runStartupBootstrapRuntimeService({
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) => {
createMecabTokenizerAndCheck: async () => {
const tokenizer = new MecabTokenizer();
mecabTokenizer = tokenizer;
await tokenizer.checkAvailability();
},
}),
createSubtitleTimingTracker: () =>
createSubtitleTimingTrackerRuntimeService({
createSubtitleTimingTracker: () => new SubtitleTimingTracker(),
setSubtitleTimingTracker: (tracker) => {
createSubtitleTimingTracker: () => {
const tracker = new SubtitleTimingTracker();
subtitleTimingTracker = tracker;
},
}),
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
@@ -596,53 +625,31 @@ 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();
}
},
});
},
shouldRestoreWindowsOnActivate: () =>
overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0,
restoreWindowsOnActivate: () => {
@@ -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),