Refactor startup/logging service wiring and related test/config updates

This commit is contained in:
2026-02-15 21:02:54 -08:00
parent c6ac962f7a
commit bec69d1b71
41 changed files with 722 additions and 281 deletions

View File

@@ -2,6 +2,7 @@ import { ipcMain, IpcMainEvent } from "electron";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { createLogger } from "../../logger";
import {
JimakuApiResponse,
JimakuDownloadQuery,
@@ -16,6 +17,8 @@ import {
KikuMergePreviewResponse,
} from "../../types";
const logger = createLogger("main:anki-jimaku-ipc");
export interface AnkiJimakuIpcDeps {
setAnkiConnectEnabled: (enabled: boolean) => void;
clearAnkiHistory: () => void;
@@ -147,7 +150,7 @@ export function registerAnkiJimakuIpcHandlers(
}
}
console.log(
logger.info(
`[jimaku] download-file name="${query.name}" entryId=${query.entryId}`,
);
const result = await deps.downloadToFile(query.url, targetPath, {
@@ -156,10 +159,10 @@ export function registerAnkiJimakuIpcHandlers(
});
if (result.ok) {
console.log(`[jimaku] download-file saved to ${result.path}`);
logger.info(`[jimaku] download-file saved to ${result.path}`);
deps.onDownloadedSubtitle(result.path);
} else {
console.error(
logger.error(
`[jimaku] download-file failed: ${result.error?.error ?? "unknown error"}`,
);
}

View File

@@ -11,6 +11,7 @@ import {
} from "../../types";
import { sortJimakuFiles } from "../../jimaku/utils";
import type { AnkiJimakuIpcDeps } from "./anki-jimaku-ipc-service";
import { createLogger } from "../../logger";
export type RegisterAnkiJimakuIpcRuntimeHandler = (
deps: AnkiJimakuIpcDeps,
@@ -62,6 +63,8 @@ export interface AnkiJimakuIpcRuntimeOptions {
) => Promise<{ ok: true; path: string } | { ok: false; error: { error: string; code?: number; retryAfter?: number } }>;
}
const logger = createLogger("main:anki-jimaku");
export function registerAnkiJimakuIpcRuntimeService(
options: AnkiJimakuIpcRuntimeOptions,
registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler,
@@ -96,11 +99,11 @@ export function registerAnkiJimakuIpcRuntimeService(
);
integration.start();
options.setAnkiIntegration(integration);
console.log("AnkiConnect integration enabled");
logger.info("AnkiConnect integration enabled");
} else if (!enabled && ankiIntegration) {
ankiIntegration.destroy();
options.setAnkiIntegration(null);
console.log("AnkiConnect integration disabled");
logger.info("AnkiConnect integration disabled");
}
options.broadcastRuntimeOptionsChanged();
@@ -109,7 +112,7 @@ export function registerAnkiJimakuIpcRuntimeService(
const subtitleTimingTracker = options.getSubtitleTimingTracker();
if (subtitleTimingTracker) {
subtitleTimingTracker.cleanup();
console.log("AnkiConnect subtitle timing history cleared");
logger.info("AnkiConnect subtitle timing history cleared");
}
},
refreshKnownWords: async () => {
@@ -139,7 +142,7 @@ export function registerAnkiJimakuIpcRuntimeService(
},
getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()),
searchJimakuEntries: async (query) => {
console.log(`[jimaku] search-entries query: "${query.query}"`);
logger.info(`[jimaku] search-entries query: "${query.query}"`);
const response = await options.jimakuFetchJson<JimakuEntry[]>(
"/api/entries/search",
{
@@ -149,13 +152,13 @@ export function registerAnkiJimakuIpcRuntimeService(
);
if (!response.ok) return response;
const maxResults = options.getJimakuMaxEntryResults();
console.log(
logger.info(
`[jimaku] search-entries returned ${response.data.length} results (capped to ${maxResults})`,
);
return { ok: true, data: response.data.slice(0, maxResults) };
},
listJimakuFiles: async (query) => {
console.log(
logger.info(
`[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? "all"}`,
);
const response = await options.jimakuFetchJson<JimakuFileEntry[]>(
@@ -169,7 +172,7 @@ export function registerAnkiJimakuIpcRuntimeService(
response.data,
options.getJimakuLanguagePreference(),
);
console.log(`[jimaku] list-files returned ${sorted.length} files`);
logger.info(`[jimaku] list-files returned ${sorted.length} files`);
return { ok: true, data: sorted };
},
resolveJimakuApiKey: () => options.resolveJimakuApiKey(),

View File

@@ -1,4 +1,7 @@
import { CliArgs, CliCommandSource } from "../../cli/args";
import { createLogger } from "../../logger";
const logger = createLogger("main:app-lifecycle");
export interface AppLifecycleServiceDeps {
shouldStartApp: (args: CliArgs) => boolean;
@@ -57,7 +60,7 @@ export function createAppLifecycleDepsRuntimeService(
logNoRunningInstance: options.logNoRunningInstance,
whenReady: (handler) => {
options.app.whenReady().then(handler).catch((error) => {
console.error("App ready handler failed:", error);
logger.error("App ready handler failed:", error);
});
},
onWindowAllClosed: (handler) => {
@@ -91,7 +94,7 @@ export function startAppLifecycleService(
try {
deps.handleCliCommand(deps.parseArgs(argv), "second-instance");
} catch (error) {
console.error("Failed to handle second-instance CLI command:", error);
logger.error("Failed to handle second-instance CLI command:", error);
}
});

View File

@@ -12,6 +12,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
getResolvedConfig: () => ({ websocket: { enabled: "auto" }, secondarySub: {} }),
getConfigWarnings: () => [],
logConfigWarning: () => calls.push("logConfigWarning"),
setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`),
initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"),
setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`),
defaultSecondarySubMode: "hover",
@@ -55,3 +56,15 @@ test("runAppReadyRuntimeService logs defer message when overlay not auto-started
),
);
});
test("runAppReadyRuntimeService applies config logging level during app-ready", async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
websocket: { enabled: "auto" },
secondarySub: {},
logging: { level: "warn" },
}),
});
await runAppReadyRuntimeService(deps);
assert.ok(calls.includes("setLogLevel:warn:config"));
});

View File

@@ -11,7 +11,6 @@ export {
} 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,
@@ -23,18 +22,14 @@ export {
} from "./mining-service";
export { createAppLifecycleDepsRuntimeService, startAppLifecycleService } from "./app-lifecycle-service";
export {
playNextSubtitleRuntimeService,
replayCurrentSubtitleRuntimeService,
sendMpvCommandRuntimeService,
setMpvSubVisibilityRuntimeService,
showMpvOsdRuntimeService,
} from "./mpv-control-service";
cycleSecondarySubModeService,
} from "./subtitle-position-service";
export {
getInitialInvisibleOverlayVisibilityService,
isAutoUpdateEnabledRuntimeService,
shouldAutoInitializeOverlayRuntimeFromConfigService,
shouldBindVisibleOverlayToMpvSubVisibilityService,
} from "./runtime-config-service";
} from "./startup-service";
export { openYomitanSettingsWindow } from "./yomitan-settings-service";
export { createTokenizerDepsRuntimeService, tokenizeSubtitleService } from "./tokenizer-service";
export { createJlptVocabularyLookupService } from "./jlpt-vocab-service";
@@ -74,7 +69,18 @@ export {
updateInvisibleOverlayVisibilityService,
updateVisibleOverlayVisibilityService,
} from "./overlay-visibility-service";
export { MpvIpcClient, MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from "./mpv-service";
export {
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
MpvIpcClient,
MpvRuntimeClientLike,
MpvTrackProperty,
playNextSubtitleRuntimeService,
replayCurrentSubtitleRuntimeService,
resolveCurrentAudioStreamIndex,
sendMpvCommandRuntimeService,
setMpvSubVisibilityRuntimeService,
showMpvOsdRuntimeService,
} from "./mpv-service";
export {
applyMpvSubtitleRenderMetricsPatchService,
DEFAULT_MPV_SUBTITLE_RENDER_METRICS,

View File

@@ -6,7 +6,7 @@ import {
sendMpvCommandRuntimeService,
setMpvSubVisibilityRuntimeService,
showMpvOsdRuntimeService,
} from "./mpv-control-service";
} from "./mpv-service";
test("showMpvOsdRuntimeService sends show-text when connected", () => {
const commands: (string | number)[][] = [];

View File

@@ -1,49 +0,0 @@
export interface MpvRuntimeClientLike {
connected: boolean;
send: (payload: { command: (string | number)[] }) => void;
replayCurrentSubtitle?: () => void;
playNextSubtitle?: () => void;
setSubVisibility?: (visible: boolean) => void;
}
export function showMpvOsdRuntimeService(
mpvClient: MpvRuntimeClientLike | null,
text: string,
fallbackLog: (text: string) => void = console.log,
): void {
if (mpvClient && mpvClient.connected) {
mpvClient.send({ command: ["show-text", text, "3000"] });
return;
}
fallbackLog(`OSD (MPV not connected): ${text}`);
}
export function replayCurrentSubtitleRuntimeService(
mpvClient: MpvRuntimeClientLike | null,
): void {
if (!mpvClient?.replayCurrentSubtitle) return;
mpvClient.replayCurrentSubtitle();
}
export function playNextSubtitleRuntimeService(
mpvClient: MpvRuntimeClientLike | null,
): void {
if (!mpvClient?.playNextSubtitle) return;
mpvClient.playNextSubtitle();
}
export function sendMpvCommandRuntimeService(
mpvClient: MpvRuntimeClientLike | null,
command: (string | number)[],
): void {
if (!mpvClient) return;
mpvClient.send({ command });
}
export function setMpvSubVisibilityRuntimeService(
mpvClient: MpvRuntimeClientLike | null,
visible: boolean,
): void {
if (!mpvClient?.setSubVisibility) return;
mpvClient.setSubVisibility(visible);
}

View File

@@ -17,12 +17,86 @@ import {
scheduleMpvReconnect,
MpvSocketTransport,
} from "./mpv-transport";
import { resolveCurrentAudioStreamIndex } from "./mpv-state";
import { createLogger } from "../../logger";
const isDebugLoggingEnabled = (): boolean => {
return (process.env.SUBMINER_LOG_LEVEL || "").toLowerCase() === "debug";
const logger = createLogger("main:mpv");
export type MpvTrackProperty = {
type?: string;
id?: number;
selected?: boolean;
"ff-index"?: number;
};
export function resolveCurrentAudioStreamIndex(
tracks: Array<MpvTrackProperty> | null | undefined,
currentAudioTrackId: number | null,
): number | null {
if (!Array.isArray(tracks)) {
return null;
}
const audioTracks = tracks.filter((track) => track.type === "audio");
const activeTrack =
audioTracks.find((track) => track.id === currentAudioTrackId) ||
audioTracks.find((track) => track.selected === true);
const ffIndex = activeTrack?.["ff-index"];
return typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0
? ffIndex
: null;
}
export interface MpvRuntimeClientLike {
connected: boolean;
send: (payload: { command: (string | number)[] }) => void;
replayCurrentSubtitle?: () => void;
playNextSubtitle?: () => void;
setSubVisibility?: (visible: boolean) => void;
}
export function showMpvOsdRuntimeService(
mpvClient: MpvRuntimeClientLike | null,
text: string,
fallbackLog: (text: string) => void = (line) => logger.info(line),
): void {
if (mpvClient && mpvClient.connected) {
mpvClient.send({ command: ["show-text", text, "3000"] });
return;
}
fallbackLog(`OSD (MPV not connected): ${text}`);
}
export function replayCurrentSubtitleRuntimeService(
mpvClient: MpvRuntimeClientLike | null,
): void {
if (!mpvClient?.replayCurrentSubtitle) return;
mpvClient.replayCurrentSubtitle();
}
export function playNextSubtitleRuntimeService(
mpvClient: MpvRuntimeClientLike | null,
): void {
if (!mpvClient?.playNextSubtitle) return;
mpvClient.playNextSubtitle();
}
export function sendMpvCommandRuntimeService(
mpvClient: MpvRuntimeClientLike | null,
command: (string | number)[],
): void {
if (!mpvClient) return;
mpvClient.send({ command });
}
export function setMpvSubVisibilityRuntimeService(
mpvClient: MpvRuntimeClientLike | null,
visible: boolean,
): void {
if (!mpvClient?.setSubVisibility) return;
mpvClient.setSubVisibility(visible);
}
export {
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
} from "./mpv-protocol";
@@ -104,7 +178,7 @@ export class MpvIpcClient implements MpvClient {
this.transport = new MpvSocketTransport({
socketPath,
onConnect: () => {
console.log("Connected to MPV socket");
logger.info("Connected to MPV socket");
this.connected = true;
this.connecting = false;
this.socket = this.transport.getSocket();
@@ -118,7 +192,7 @@ export class MpvIpcClient implements MpvClient {
this.deps.autoStartOverlay ||
this.deps.getResolvedConfig().auto_start_overlay === true;
if (this.firstConnection && shouldAutoStart) {
console.log("Auto-starting overlay, hiding mpv subtitles");
logger.info("Auto-starting overlay, hiding mpv subtitles");
setTimeout(() => {
this.deps.setOverlayVisible(true);
}, 100);
@@ -133,15 +207,11 @@ export class MpvIpcClient implements MpvClient {
this.processBuffer();
},
onError: (err: Error) => {
if (isDebugLoggingEnabled()) {
console.error("MPV socket error:", err.message);
}
logger.debug("MPV socket error:", err.message);
this.failPendingRequests();
},
onClose: () => {
if (isDebugLoggingEnabled()) {
console.log("MPV socket closed");
}
logger.debug("MPV socket closed");
this.connected = false;
this.connecting = false;
this.socket = null;
@@ -202,11 +272,9 @@ export class MpvIpcClient implements MpvClient {
getReconnectTimer: () => this.deps.getReconnectTimer(),
setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer),
onReconnectAttempt: (attempt, delay) => {
if (isDebugLoggingEnabled()) {
console.log(
`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`,
);
}
logger.debug(
`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`,
);
},
connect: () => {
this.connect();
@@ -221,7 +289,7 @@ export class MpvIpcClient implements MpvClient {
this.handleMessage(message);
},
(line, error) => {
console.error("Failed to parse MPV message:", line, error);
logger.error("Failed to parse MPV message:", line, error);
},
);
this.buffer = parsed.nextBuffer;

View File

@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCurrentAudioStreamIndex } from "./mpv-state";
import { resolveCurrentAudioStreamIndex } from "./mpv-service";
test("resolveCurrentAudioStreamIndex returns selected ff-index when no current track id", () => {
assert.equal(

View File

@@ -1,25 +0,0 @@
export type MpvTrackProperty = {
type?: string;
id?: number;
selected?: boolean;
"ff-index"?: number;
};
export function resolveCurrentAudioStreamIndex(
tracks: Array<MpvTrackProperty> | null | undefined,
currentAudioTrackId: number | null,
): number | null {
if (!Array.isArray(tracks)) {
return null;
}
const audioTracks = tracks.filter((track) => track.type === "audio");
const activeTrack =
audioTracks.find((track) => track.id === currentAudioTrackId) ||
audioTracks.find((track) => track.selected === true);
const ffIndex = activeTrack?.["ff-index"];
return typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0
? ffIndex
: null;
}

View File

@@ -1,4 +1,7 @@
import { OverlayContentMeasurement, OverlayContentRect, OverlayLayer } from "../../types";
import { createLogger } from "../../logger";
const logger = createLogger("main:overlay-content-measurement");
const MAX_VIEWPORT = 10000;
const MAX_RECT_DIMENSION = 10000;
const MAX_RECT_OFFSET = 50000;
@@ -107,7 +110,7 @@ export function createOverlayContentMeasurementStoreService(options?: {
warn?: (message: string) => void;
}) {
const now = options?.now ?? (() => Date.now());
const warn = options?.warn ?? ((message: string) => console.warn(message));
const warn = options?.warn ?? ((message: string) => logger.warn(message));
const latestByLayer: OverlayMeasurementStore = {
visible: null,
invisible: null,

View File

@@ -1,5 +1,8 @@
import { ConfiguredShortcuts } from "../utils/shortcut-config";
import { OverlayShortcutHandlers } from "./overlay-shortcut-service";
import { createLogger } from "../../logger";
const logger = createLogger("main:overlay-shortcut-handler");
export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void;
@@ -38,7 +41,7 @@ function wrapAsync(
): () => void {
return () => {
task().catch((err) => {
console.error(`${logLabel} failed:`, err);
logger.error(`${logLabel} failed:`, err);
deps.showMpvOsd(`${osdLabel}: ${(err as Error).message}`);
});
};

View File

@@ -1,6 +1,9 @@
import { globalShortcut } from "electron";
import { ConfiguredShortcuts } from "../utils/shortcut-config";
import { isGlobalShortcutRegisteredSafe } from "./shortcut-fallback-service";
import { createLogger } from "../../logger";
const logger = createLogger("main:overlay-shortcut-service");
export interface OverlayShortcutHandlers {
copySubtitle: () => void;
@@ -39,7 +42,7 @@ export function registerOverlayShortcutsService(
}
const ok = globalShortcut.register(accelerator, handler);
if (!ok) {
console.warn(
logger.warn(
`Failed to register overlay shortcut ${label}: ${accelerator}`,
);
return;

View File

@@ -1,6 +1,9 @@
import { BrowserWindow } from "electron";
import * as path from "path";
import { WindowGeometry } from "../../types";
import { createLogger } from "../../logger";
const logger = createLogger("main:overlay-window");
export type OverlayWindowKind = "visible" | "invisible";
@@ -86,13 +89,13 @@ export function createOverlayWindowService(
query: { layer: kind === "visible" ? "visible" : "invisible" },
})
.catch((err) => {
console.error("Failed to load HTML file:", err);
logger.error("Failed to load HTML file:", err);
});
window.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL) => {
console.error(
logger.error(
"Page failed to load:",
errorCode,
errorDescription,

View File

@@ -5,7 +5,7 @@ import {
isAutoUpdateEnabledRuntimeService,
shouldAutoInitializeOverlayRuntimeFromConfigService,
shouldBindVisibleOverlayToMpvSubVisibilityService,
} from "./runtime-config-service";
} from "./startup-service";
const BASE_CONFIG = {
auto_start_overlay: false,

View File

@@ -1,50 +0,0 @@
interface RuntimeAutoUpdateOptionManagerLike {
getOptionValue: (id: "anki.autoUpdateNewCards") => unknown;
}
interface RuntimeConfigLike {
auto_start_overlay?: boolean;
bind_visible_overlay_to_mpv_sub_visibility: boolean;
invisibleOverlay: {
startupVisibility: "visible" | "hidden" | "platform-default";
};
ankiConnect?: {
behavior?: {
autoUpdateNewCards?: boolean;
};
};
}
export function getInitialInvisibleOverlayVisibilityService(
config: RuntimeConfigLike,
platform: NodeJS.Platform,
): boolean {
const visibility = config.invisibleOverlay.startupVisibility;
if (visibility === "visible") return true;
if (visibility === "hidden") return false;
if (platform === "linux") return false;
return true;
}
export function shouldAutoInitializeOverlayRuntimeFromConfigService(
config: RuntimeConfigLike,
): boolean {
if (config.auto_start_overlay === true) return true;
if (config.invisibleOverlay.startupVisibility === "visible") return true;
return false;
}
export function shouldBindVisibleOverlayToMpvSubVisibilityService(
config: RuntimeConfigLike,
): boolean {
return config.bind_visible_overlay_to_mpv_sub_visibility;
}
export function isAutoUpdateEnabledRuntimeService(
config: RuntimeConfigLike,
runtimeOptionsManager: RuntimeAutoUpdateOptionManagerLike | null,
): boolean {
const value = runtimeOptionsManager?.getOptionValue("anki.autoUpdateNewCards");
if (typeof value === "boolean") return value;
return config.ankiConnect?.behavior?.autoUpdateNewCards !== false;
}

View File

@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { SecondarySubMode } from "../../types";
import { cycleSecondarySubModeService } from "./secondary-subtitle-service";
import { cycleSecondarySubModeService } from "./subtitle-position-service";
test("cycleSecondarySubModeService cycles and emits broadcast + OSD", () => {
let mode: SecondarySubMode = "hover";

View File

@@ -1,32 +0,0 @@
import { SecondarySubMode } from "../../types";
export interface CycleSecondarySubModeDeps {
getSecondarySubMode: () => SecondarySubMode;
setSecondarySubMode: (mode: SecondarySubMode) => void;
getLastSecondarySubToggleAtMs: () => number;
setLastSecondarySubToggleAtMs: (timestampMs: number) => void;
broadcastSecondarySubMode: (mode: SecondarySubMode) => void;
showMpvOsd: (text: string) => void;
now?: () => number;
}
const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ["hidden", "visible", "hover"];
const SECONDARY_SUB_TOGGLE_DEBOUNCE_MS = 120;
export function cycleSecondarySubModeService(
deps: CycleSecondarySubModeDeps,
): void {
const now = deps.now ? deps.now() : Date.now();
if (now - deps.getLastSecondarySubToggleAtMs() < SECONDARY_SUB_TOGGLE_DEBOUNCE_MS) {
return;
}
deps.setLastSecondarySubToggleAtMs(now);
const currentMode = deps.getSecondarySubMode();
const currentIndex = SECONDARY_SUB_CYCLE.indexOf(currentMode);
const nextMode =
SECONDARY_SUB_CYCLE[(currentIndex + 1) % SECONDARY_SUB_CYCLE.length];
deps.setSecondarySubMode(nextMode);
deps.broadcastSecondarySubMode(nextMode);
deps.showMpvOsd(`Secondary subtitle: ${nextMode}`);
}

View File

@@ -1,4 +1,7 @@
import { BrowserWindow, globalShortcut } from "electron";
import { createLogger } from "../../logger";
const logger = createLogger("main:shortcut");
export interface GlobalShortcutConfig {
toggleVisibleOverlayGlobal: string | null | undefined;
@@ -38,7 +41,7 @@ export function registerGlobalShortcutsService(
},
);
if (!toggleVisibleRegistered) {
console.warn(
logger.warn(
`Failed to register global shortcut toggleVisibleOverlayGlobal: ${visibleShortcut}`,
);
}
@@ -56,7 +59,7 @@ export function registerGlobalShortcutsService(
},
);
if (!toggleInvisibleRegistered) {
console.warn(
logger.warn(
`Failed to register global shortcut toggleInvisibleOverlayGlobal: ${invisibleShortcut}`,
);
}
@@ -65,7 +68,7 @@ export function registerGlobalShortcutsService(
normalizedInvisible &&
normalizedInvisible === normalizedVisible
) {
console.warn(
logger.warn(
"Skipped registering toggleInvisibleOverlayGlobal because it collides with toggleVisibleOverlayGlobal",
);
}
@@ -77,7 +80,7 @@ export function registerGlobalShortcutsService(
normalizedJimaku === normalizedInvisible ||
normalizedJimaku === normalizedSettings)
) {
console.warn(
logger.warn(
"Skipped registering openJimaku because it collides with another global shortcut",
);
} else {
@@ -88,7 +91,7 @@ export function registerGlobalShortcutsService(
},
);
if (!openJimakuRegistered) {
console.warn(
logger.warn(
`Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`,
);
}
@@ -99,7 +102,7 @@ export function registerGlobalShortcutsService(
options.onOpenYomitanSettings();
});
if (!settingsRegistered) {
console.warn("Failed to register global shortcut: Alt+Shift+Y");
logger.warn("Failed to register global shortcut: Alt+Shift+Y");
}
if (options.isDev) {
@@ -110,7 +113,7 @@ export function registerGlobalShortcutsService(
}
});
if (!devtoolsRegistered) {
console.warn("Failed to register global shortcut: F12");
logger.warn("Failed to register global shortcut: F12");
}
}
}

View File

@@ -54,8 +54,7 @@ test("runStartupBootstrapRuntimeService configures startup state and starts life
const result = runStartupBootstrapRuntimeService({
argv: ["node", "main.ts", "--verbose"],
parseArgs: () => args,
setLogLevelEnv: (level) => calls.push(`setLog:${level}`),
enableVerboseLogging: () => calls.push("enableVerbose"),
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push("forceX11"),
enforceUnsupportedWaylandMode: () => calls.push("enforceWayland"),
getDefaultSocketPath: () => "/tmp/default.sock",
@@ -71,13 +70,39 @@ test("runStartupBootstrapRuntimeService configures startup state and starts life
assert.equal(result.autoStartOverlay, true);
assert.equal(result.texthookerOnlyMode, true);
assert.deepEqual(calls, [
"enableVerbose",
"setLog:debug:cli",
"forceX11",
"enforceWayland",
"startLifecycle",
]);
});
test("runStartupBootstrapRuntimeService prefers --log-level over --verbose", () => {
const calls: string[] = [];
const args = makeArgs({
logLevel: "warn",
verbose: true,
});
runStartupBootstrapRuntimeService({
argv: ["node", "main.ts", "--log-level", "warn", "--verbose"],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push("forceX11"),
enforceUnsupportedWaylandMode: () => calls.push("enforceWayland"),
getDefaultSocketPath: () => "/tmp/default.sock",
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push("startLifecycle"),
});
assert.deepEqual(calls.slice(0, 3), [
"setLog:warn:cli",
"forceX11",
"enforceWayland",
]);
});
test("runStartupBootstrapRuntimeService skips lifecycle when generate-config flow handled", () => {
const calls: string[] = [];
const args = makeArgs({ generateConfig: true, logLevel: "warn" });
@@ -85,8 +110,7 @@ test("runStartupBootstrapRuntimeService skips lifecycle when generate-config flo
const result = runStartupBootstrapRuntimeService({
argv: ["node", "main.ts", "--generate-config"],
parseArgs: () => args,
setLogLevelEnv: (level) => calls.push(`setLog:${level}`),
enableVerboseLogging: () => calls.push("enableVerbose"),
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push("forceX11"),
enforceUnsupportedWaylandMode: () => calls.push("enforceWayland"),
getDefaultSocketPath: () => "/tmp/default.sock",
@@ -99,7 +123,7 @@ test("runStartupBootstrapRuntimeService skips lifecycle when generate-config flo
assert.equal(result.texthookerPort, 5174);
assert.equal(result.backendOverride, null);
assert.deepEqual(calls, [
"setLog:warn",
"setLog:warn:cli",
"forceX11",
"enforceWayland",
]);

View File

@@ -1,5 +1,6 @@
import { CliArgs } from "../../cli/args";
import { ConfigValidationWarning, SecondarySubMode } from "../../types";
import type { LogLevelSource } from "../../logger";
import { ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from "../../types";
export interface StartupBootstrapRuntimeState {
initialArgs: CliArgs;
@@ -10,11 +11,27 @@ export interface StartupBootstrapRuntimeState {
texthookerOnlyMode: boolean;
}
interface RuntimeAutoUpdateOptionManagerLike {
getOptionValue: (id: "anki.autoUpdateNewCards") => unknown;
}
export interface RuntimeConfigLike {
auto_start_overlay?: boolean;
bind_visible_overlay_to_mpv_sub_visibility: boolean;
invisibleOverlay: {
startupVisibility: "visible" | "hidden" | "platform-default";
};
ankiConnect?: {
behavior?: {
autoUpdateNewCards?: boolean;
};
};
}
export interface StartupBootstrapRuntimeDeps {
argv: string[];
parseArgs: (argv: string[]) => CliArgs;
setLogLevelEnv: (level: string) => void;
enableVerboseLogging: () => void;
setLogLevel: (level: string, source: LogLevelSource) => void;
forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
getDefaultSocketPath: () => string;
@@ -29,9 +46,9 @@ export function runStartupBootstrapRuntimeService(
const initialArgs = deps.parseArgs(deps.argv);
if (initialArgs.logLevel) {
deps.setLogLevelEnv(initialArgs.logLevel);
deps.setLogLevel(initialArgs.logLevel, "cli");
} else if (initialArgs.verbose) {
deps.enableVerboseLogging();
deps.setLogLevel("debug", "cli");
}
deps.forceX11Backend(initialArgs);
@@ -61,6 +78,9 @@ interface AppReadyConfigLike {
enabled?: boolean | "auto";
port?: number;
};
logging?: {
level?: "debug" | "info" | "warn" | "error";
};
}
export interface AppReadyRuntimeDeps {
@@ -71,6 +91,7 @@ export interface AppReadyRuntimeDeps {
getResolvedConfig: () => AppReadyConfigLike;
getConfigWarnings: () => ConfigValidationWarning[];
logConfigWarning: (warning: ConfigValidationWarning) => void;
setLogLevel: (level: string, source: LogLevelSource) => void;
initRuntimeOptionsManager: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
defaultSecondarySubMode: SecondarySubMode;
@@ -87,6 +108,40 @@ export interface AppReadyRuntimeDeps {
handleInitialArgs: () => void;
}
export function getInitialInvisibleOverlayVisibilityService(
config: RuntimeConfigLike,
platform: NodeJS.Platform,
): boolean {
const visibility = config.invisibleOverlay.startupVisibility;
if (visibility === "visible") return true;
if (visibility === "hidden") return false;
if (platform === "linux") return false;
return true;
}
export function shouldAutoInitializeOverlayRuntimeFromConfigService(
config: RuntimeConfigLike,
): boolean {
if (config.auto_start_overlay === true) return true;
if (config.invisibleOverlay.startupVisibility === "visible") return true;
return false;
}
export function shouldBindVisibleOverlayToMpvSubVisibilityService(
config: RuntimeConfigLike,
): boolean {
return config.bind_visible_overlay_to_mpv_sub_visibility;
}
export function isAutoUpdateEnabledRuntimeService(
config: ResolvedConfig | RuntimeConfigLike,
runtimeOptionsManager: RuntimeAutoUpdateOptionManagerLike | null,
): boolean {
const value = runtimeOptionsManager?.getOptionValue("anki.autoUpdateNewCards");
if (typeof value === "boolean") return value;
return (config as ResolvedConfig).ankiConnect?.behavior?.autoUpdateNewCards !== false;
}
export async function runAppReadyRuntimeService(
deps: AppReadyRuntimeDeps,
): Promise<void> {
@@ -97,6 +152,7 @@ export async function runAppReadyRuntimeService(
deps.reloadConfig();
const config = deps.getResolvedConfig();
deps.setLogLevel(config.logging?.level ?? "info", "config");
for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning);
}

View File

@@ -19,6 +19,9 @@ import {
SubsyncResolvedConfig,
} from "../../subsync/utils";
import { isRemoteMediaPath } from "../../jimaku/utils";
import { createLogger } from "../../logger";
const logger = createLogger("main:subsync");
interface FileExtractionResult {
path: string;
@@ -361,7 +364,7 @@ async function runSubsyncAutoInternal(
return alassResult;
}
} catch (error) {
console.warn("Auto alass sync failed, trying ffsubsync fallback:", error);
logger.warn("Auto alass sync failed, trying ffsubsync fallback:", error);
} finally {
if (secondaryExtraction) {
cleanupTemporaryFile(secondaryExtraction);

View File

@@ -1,7 +1,40 @@
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import { SubtitlePosition } from "../../types";
import { SecondarySubMode, SubtitlePosition } from "../../types";
import { createLogger } from "../../logger";
const logger = createLogger("main:subtitle-position");
export interface CycleSecondarySubModeDeps {
getSecondarySubMode: () => SecondarySubMode;
setSecondarySubMode: (mode: SecondarySubMode) => void;
getLastSecondarySubToggleAtMs: () => number;
setLastSecondarySubToggleAtMs: (timestampMs: number) => void;
broadcastSecondarySubMode: (mode: SecondarySubMode) => void;
showMpvOsd: (text: string) => void;
now?: () => number;
}
const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ["hidden", "visible", "hover"];
const SECONDARY_SUB_TOGGLE_DEBOUNCE_MS = 120;
export function cycleSecondarySubModeService(
deps: CycleSecondarySubModeDeps,
): void {
const now = deps.now ? deps.now() : Date.now();
if (now - deps.getLastSecondarySubToggleAtMs() < SECONDARY_SUB_TOGGLE_DEBOUNCE_MS) {
return;
}
deps.setLastSecondarySubToggleAtMs(now);
const currentMode = deps.getSecondarySubMode();
const currentIndex = SECONDARY_SUB_CYCLE.indexOf(currentMode);
const nextMode = SECONDARY_SUB_CYCLE[(currentIndex + 1) % SECONDARY_SUB_CYCLE.length];
deps.setSecondarySubMode(nextMode);
deps.broadcastSecondarySubMode(nextMode);
deps.showMpvOsd(`Secondary subtitle: ${nextMode}`);
}
function getSubtitlePositionFilePath(
mediaPath: string,
@@ -97,7 +130,7 @@ export function loadSubtitlePositionService(options: {
}
return options.fallbackPosition;
} catch (err) {
console.error("Failed to load subtitle position:", (err as Error).message);
logger.error("Failed to load subtitle position:", (err as Error).message);
return options.fallbackPosition;
}
}
@@ -111,7 +144,7 @@ export function saveSubtitlePositionService(options: {
}): void {
if (!options.currentMediaPath) {
options.onQueuePending(options.position);
console.warn("Queued subtitle position save - no media path yet");
logger.warn("Queued subtitle position save - no media path yet");
return;
}
@@ -123,7 +156,7 @@ export function saveSubtitlePositionService(options: {
);
options.onPersisted();
} catch (err) {
console.error("Failed to save subtitle position:", (err as Error).message);
logger.error("Failed to save subtitle position:", (err as Error).message);
}
}
@@ -154,8 +187,8 @@ export function updateCurrentMediaPathService(options: {
);
options.setSubtitlePosition(options.pendingSubtitlePosition);
options.clearPendingSubtitlePosition();
} catch (err) {
console.error(
} catch (err) {
logger.error(
"Failed to persist queued subtitle position:",
(err as Error).message,
);

View File

@@ -2,6 +2,9 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import WebSocket from "ws";
import { createLogger } from "../../logger";
const logger = createLogger("main:subtitle-ws");
export function hasMpvWebsocketPlugin(): boolean {
const mpvWebsocketPath = path.join(
@@ -24,7 +27,7 @@ export class SubtitleWebSocketService {
this.server = new WebSocket.Server({ port, host: "127.0.0.1" });
this.server.on("connection", (ws: WebSocket) => {
console.log("WebSocket client connected");
logger.info("WebSocket client connected");
const currentText = getCurrentSubtitleText();
if (currentText) {
ws.send(JSON.stringify({ sentence: currentText }));
@@ -32,10 +35,10 @@ export class SubtitleWebSocketService {
});
this.server.on("error", (err: Error) => {
console.error("WebSocket server error:", err.message);
logger.error("WebSocket server error:", err.message);
});
console.log(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`);
logger.info(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`);
}
public broadcast(text: string): void {

View File

@@ -1,6 +1,9 @@
import * as fs from "fs";
import * as http from "http";
import * as path from "path";
import { createLogger } from "../../logger";
const logger = createLogger("main:texthooker");
export class TexthookerService {
private server: http.Server | null = null;
@@ -12,7 +15,7 @@ export class TexthookerService {
public start(port: number): http.Server | null {
const texthookerPath = this.getTexthookerPath();
if (!texthookerPath) {
console.error("texthooker-ui not found");
logger.error("texthooker-ui not found");
return null;
}
@@ -48,7 +51,7 @@ export class TexthookerService {
});
this.server.listen(port, "127.0.0.1", () => {
console.log(`Texthooker server running at http://127.0.0.1:${port}`);
logger.info(`Texthooker server running at http://127.0.0.1:${port}`);
});
return this.server;

View File

@@ -1,6 +1,9 @@
import { BrowserWindow, Extension, session } from "electron";
import * as fs from "fs";
import * as path from "path";
import { createLogger } from "../../logger";
const logger = createLogger("main:yomitan-extension-loader");
export interface YomitanExtensionLoaderDeps {
userDataPath: string;
@@ -49,7 +52,7 @@ function ensureExtensionCopy(sourceDir: string, userDataPath: string): string {
fs.mkdirSync(extensionsRoot, { recursive: true });
fs.rmSync(targetDir, { recursive: true, force: true });
fs.cpSync(sourceDir, targetDir, { recursive: true });
console.log(`Copied yomitan extension to ${targetDir}`);
logger.info(`Copied yomitan extension to ${targetDir}`);
}
return targetDir;
@@ -75,8 +78,8 @@ export async function loadYomitanExtensionService(
}
if (!extPath) {
console.error("Yomitan extension not found in any search path");
console.error("Install Yomitan to one of:", searchPaths);
logger.error("Yomitan extension not found in any search path");
logger.error("Install Yomitan to one of:", searchPaths);
return null;
}
@@ -102,8 +105,8 @@ export async function loadYomitanExtensionService(
deps.setYomitanExtension(extension);
return extension;
} catch (err) {
console.error("Failed to load Yomitan extension:", (err as Error).message);
console.error("Full error:", err);
logger.error("Failed to load Yomitan extension:", (err as Error).message);
logger.error("Full error:", err);
deps.setYomitanExtension(null);
return null;
}

View File

@@ -1,4 +1,7 @@
import { BrowserWindow, Extension, session } from "electron";
import { createLogger } from "../../logger";
const logger = createLogger("main:yomitan-settings");
export interface OpenYomitanSettingsWindowOptions {
yomitanExt: Extension | null;
@@ -9,14 +12,14 @@ export interface OpenYomitanSettingsWindowOptions {
export function openYomitanSettingsWindow(
options: OpenYomitanSettingsWindowOptions,
): void {
console.log("openYomitanSettings called");
logger.info("openYomitanSettings called");
if (!options.yomitanExt) {
console.error(
logger.error(
"Yomitan extension not loaded - yomitanExt is:",
options.yomitanExt,
);
console.error(
logger.error(
"This may be due to Manifest V3 service worker issues with Electron",
);
return;
@@ -24,12 +27,12 @@ export function openYomitanSettingsWindow(
const existingWindow = options.getExistingWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
console.log("Settings window already exists, focusing");
logger.info("Settings window already exists, focusing");
existingWindow.focus();
return;
}
console.log("Creating new settings window for extension:", options.yomitanExt.id);
logger.info("Creating new settings window for extension:", options.yomitanExt.id);
const settingsWindow = new BrowserWindow({
width: 1200,
@@ -44,7 +47,7 @@ export function openYomitanSettingsWindow(
options.setWindow(settingsWindow);
const settingsUrl = `chrome-extension://${options.yomitanExt.id}/settings.html`;
console.log("Loading settings URL:", settingsUrl);
logger.info("Loading settings URL:", settingsUrl);
let loadAttempts = 0;
const maxAttempts = 3;
@@ -53,13 +56,13 @@ export function openYomitanSettingsWindow(
settingsWindow
.loadURL(settingsUrl)
.then(() => {
console.log("Settings URL loaded successfully");
logger.info("Settings URL loaded successfully");
})
.catch((err: Error) => {
console.error("Failed to load settings URL:", err);
logger.error("Failed to load settings URL:", err);
loadAttempts++;
if (loadAttempts < maxAttempts && !settingsWindow.isDestroyed()) {
console.log(
logger.info(
`Retrying in 500ms (attempt ${loadAttempts + 1}/${maxAttempts})`,
);
setTimeout(attemptLoad, 500);
@@ -72,7 +75,7 @@ export function openYomitanSettingsWindow(
settingsWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription) => {
console.error(
logger.error(
"Settings page failed to load:",
errorCode,
errorDescription,
@@ -81,7 +84,7 @@ export function openYomitanSettingsWindow(
);
settingsWindow.webContents.on("did-finish-load", () => {
console.log("Settings page loaded successfully");
logger.info("Settings page loaded successfully");
});
setTimeout(() => {