Fix mpv tlang and profile parsing

This commit is contained in:
2026-02-18 19:04:24 -08:00
parent f299f2a19e
commit d1aeb3b754
18 changed files with 537 additions and 88 deletions

View File

@@ -170,7 +170,7 @@ This keeps side effects explicit and makes behavior easy to unit-test with fakes
## Program Lifecycle ## Program Lifecycle
- **Startup:** `startup.ts` parses CLI args and detects the compositor backend. If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks. - **Startup:** `startup.ts` parses CLI args and detects the compositor backend. If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
- **Initialization:** Once `app.whenReady()` fires, `startup-lifecycle.ts` loads config, resolves keybindings, creates the mpv client, initializes the MeCab tokenizer, starts the window tracker, and applies WebSocket policy — then creates the overlay window and establishes the IPC bridge. - **Initialization:** Once `app.whenReady()` fires, `startup-lifecycle.ts` runs a short critical path first (config reload, keybindings, mpv client, overlay setup, IPC bridge), then schedules non-critical warmups in the background (MeCab availability check, Yomitan extension load, dictionary prewarm, optional Jellyfin remote startup).
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, and keyboard shortcuts all route through the composition layer to domain services, which update state and broadcast to the renderer. - **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, and keyboard shortcuts all route through the composition layer to domain services, which update state and broadcast to the renderer.
- **Shutdown:** Electron's `will-quit` triggers service teardown — closes the mpv socket, unregisters shortcuts, stops WebSocket and texthooker servers, destroys the window tracker, and cleans up Anki state. - **Shutdown:** Electron's `will-quit` triggers service teardown — closes the mpv socket, unregisters shortcuts, stops WebSocket and texthooker servers, destroys the window tracker, and cleans up Anki state.
@@ -194,13 +194,14 @@ flowchart TD
subgraph Init["Initialization"] subgraph Init["Initialization"]
direction LR direction LR
Config["Load config<br/>resolve keybindings"]:::init Config["Load config<br/>resolve keybindings"]:::init
Runtime["Create mpv client<br/>init MeCab tokenizer"]:::init Runtime["Create mpv client<br/>init runtime options"]:::init
Platform["Start window tracker<br/>WebSocket policy"]:::init Platform["Start window tracker<br/>WebSocket policy"]:::init
end end
Init --> Create["Create overlay window<br/>Establish IPC bridge<br/>Load Yomitan extension"]:::phase Init --> Create["Create overlay window<br/>Establish IPC bridge"]:::phase
Create --> Warm["Background warmups<br/>MeCab · Yomitan · dictionaries · Jellyfin"]:::phase
Create --> Loop Warm --> Loop
subgraph Loop["Runtime — event-driven"] subgraph Loop["Runtime — event-driven"]
direction LR direction LR
Events["mpv · IPC · CLI<br/>shortcut events"]:::runtime Events["mpv · IPC · CLI<br/>shortcut events"]:::runtime

View File

@@ -13,6 +13,17 @@ Watch video → See subtitle → Click word → Yomitan lookup → Add to Anki
sentence, audio, image, translation sentence, audio, image, translation
``` ```
## Subtitle Delivery Path (Startup + Runtime)
SubMiner now prioritizes subtitle responsiveness over heavy initialization:
1. The first subtitle render is **plain text first** (no tokenization wait).
2. Tokenized enrichment (word spans, known-word flags, JLPT/frequency metadata) is applied right after parsing completes.
3. Under rapid subtitle churn, SubMiner uses a **latest-only tokenization queue** so stale lines are dropped instead of building lag.
4. MeCab, Yomitan extension load, and dictionary prewarm run as background warmups after overlay initialization.
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
## The Two Overlay Layers ## The Two Overlay Layers
SubMiner uses two overlay layers, each serving a different purpose. SubMiner uses two overlay layers, each serving a different purpose.

View File

@@ -17,7 +17,7 @@
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"test:config:dist": "node --test dist/config/config.test.js", "test:config:dist": "node --test dist/config/config.test.js",
"test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js", "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
"test": "bun run test:config && bun run test:core", "test": "bun run test:config && bun run test:core",
"test:config": "bun run build && bun run test:config:dist", "test:config": "bun run build && bun run test:config:dist",

View File

@@ -34,10 +34,18 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
loadYomitanExtension: async () => { loadYomitanExtension: async () => {
calls.push('loadYomitanExtension'); calls.push('loadYomitanExtension');
}, },
prewarmSubtitleDictionaries: async () => {
calls.push('prewarmSubtitleDictionaries');
},
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
},
texthookerOnlyMode: false, texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true, shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'), initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
handleInitialArgs: () => calls.push('handleInitialArgs'), handleInitialArgs: () => calls.push('handleInitialArgs'),
logDebug: (message) => calls.push(`debug:${message}`),
now: () => 1000,
...overrides, ...overrides,
}; };
return { deps, calls }; return { deps, calls };
@@ -51,7 +59,7 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
assert.ok(calls.includes('startSubtitleWebsocket:9001')); assert.ok(calls.includes('startSubtitleWebsocket:9001'));
assert.ok(calls.includes('initializeOverlayRuntime')); assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('createImmersionTracker')); assert.ok(calls.includes('createImmersionTracker'));
assert.ok(calls.includes('startJellyfinRemoteSession')); assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok(calls.includes('log:Runtime ready: invoking createImmersionTracker.')); assert.ok(calls.includes('log:Runtime ready: invoking createImmersionTracker.'));
}); });
@@ -63,10 +71,10 @@ test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wi
await runAppReadyRuntime(deps); await runAppReadyRuntime(deps);
assert.equal(calls.includes('startJellyfinRemoteSession'), false); assert.equal(calls.includes('startJellyfinRemoteSession'), false);
assert.ok(calls.includes('createMecabTokenizerAndCheck'));
assert.ok(calls.includes('createMpvClient')); assert.ok(calls.includes('createMpvClient'));
assert.ok(calls.includes('createSubtitleTimingTracker')); assert.ok(calls.includes('createSubtitleTimingTracker'));
assert.ok(calls.includes('handleInitialArgs')); assert.ok(calls.includes('handleInitialArgs'));
assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok( assert.ok(
calls.includes('initializeOverlayRuntime') || calls.includes('initializeOverlayRuntime') ||
calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'), calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'),
@@ -116,3 +124,28 @@ test('runAppReadyRuntime applies config logging level during app-ready', async (
await runAppReadyRuntime(deps); await runAppReadyRuntime(deps);
assert.ok(calls.includes('setLogLevel:warn:config')); assert.ok(calls.includes('setLogLevel:warn:config'));
}); });
test('runAppReadyRuntime does not await background warmups', async () => {
const calls: string[] = [];
let releaseWarmup: (() => void) | undefined;
const warmupGate = new Promise<void>((resolve) => {
releaseWarmup = resolve;
});
const { deps } = makeDeps({
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
void warmupGate.then(() => {
calls.push('warmupDone');
});
},
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
});
await runAppReadyRuntime(deps);
assert.deepEqual(calls.slice(0, 2), ['handleInitialArgs', 'startBackgroundWarmups']);
assert.equal(calls.includes('warmupDone'), false);
assert.ok(releaseWarmup);
releaseWarmup();
});

View File

@@ -30,6 +30,7 @@ export {
} from './startup'; } from './startup';
export { openYomitanSettingsWindow } from './yomitan-settings'; export { openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer'; export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export { createSubtitleProcessingController } from './subtitle-processing-controller';
export { createFrequencyDictionaryLookup } from './frequency-dictionary'; export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab'; export { createJlptVocabularyLookup } from './jlpt-vocab';
export { export {

View File

@@ -15,6 +15,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
quitApp: () => {}, quitApp: () => {},
toggleVisibleOverlay: () => {}, toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null, tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '', getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null, getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null, getSubtitlePosition: () => null,

View File

@@ -12,6 +12,7 @@ export interface IpcServiceDeps {
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
getInvisibleOverlayVisibility: () => boolean; getInvisibleOverlayVisibility: () => boolean;
tokenizeCurrentSubtitle: () => Promise<unknown>; tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string; getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown; getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
@@ -75,6 +76,7 @@ export interface IpcDepsRuntimeOptions {
quitApp: () => void; quitApp: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
tokenizeCurrentSubtitle: () => Promise<unknown>; tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string; getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown; getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
@@ -122,6 +124,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
toggleVisibleOverlay: options.toggleVisibleOverlay, toggleVisibleOverlay: options.toggleVisibleOverlay,
getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility, getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility,
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss, getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics, getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics,
getSubtitlePosition: options.getSubtitlePosition, getSubtitlePosition: options.getSubtitlePosition,
@@ -220,6 +223,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
return await deps.tokenizeCurrentSubtitle(); return await deps.tokenizeCurrentSubtitle();
}); });
ipcMain.handle('get-current-subtitle-raw', () => {
return deps.getCurrentSubtitleRaw();
});
ipcMain.handle('get-current-subtitle-ass', () => { ipcMain.handle('get-current-subtitle-ass', () => {
return deps.getCurrentSubtitleAss(); return deps.getCurrentSubtitleAss();
}); });

View File

@@ -106,10 +106,14 @@ export interface AppReadyRuntimeDeps {
createImmersionTracker?: () => void; createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>; startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>; loadYomitanExtension: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>;
startBackgroundWarmups: () => void;
texthookerOnlyMode: boolean; texthookerOnlyMode: boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
handleInitialArgs: () => void; handleInitialArgs: () => void;
logDebug?: (message: string) => void;
now?: () => number;
} }
export function getInitialInvisibleOverlayVisibility( export function getInitialInvisibleOverlayVisibility(
@@ -143,9 +147,12 @@ export function isAutoUpdateEnabledRuntime(
} }
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> { export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now();
deps.logDebug?.('App-ready critical path started.');
deps.loadSubtitlePosition(); deps.loadSubtitlePosition();
deps.resolveKeybindings(); deps.resolveKeybindings();
await deps.createMecabTokenizerAndCheck();
deps.createMpvClient(); deps.createMpvClient();
deps.reloadConfig(); deps.reloadConfig();
@@ -178,10 +185,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
} else { } else {
deps.log('Runtime ready: createImmersionTracker dependency is missing.'); deps.log('Runtime ready: createImmersionTracker dependency is missing.');
} }
await deps.loadYomitanExtension();
if (deps.startJellyfinRemoteSession) {
await deps.startJellyfinRemoteSession();
}
if (deps.texthookerOnlyMode) { if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.'); deps.log('Texthooker-only mode enabled; skipping overlay window.');
@@ -192,4 +195,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
} }
deps.handleInitialArgs(); deps.handleInitialArgs();
deps.startBackgroundWarmups();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
} }

View File

@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSubtitleProcessingController } from './subtitle-processing-controller';
import type { SubtitleData } from '../../types';
function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
test('subtitle processing emits plain subtitle immediately before tokenized payload', async () => {
const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('字幕');
assert.deepEqual(emitted[0], { text: '字幕', tokens: null });
await flushMicrotasks();
assert.deepEqual(emitted[1], { text: '字幕', tokens: [] });
});
test('subtitle processing drops stale tokenization and delivers latest subtitle only', async () => {
const emitted: SubtitleData[] = [];
let firstResolve: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
if (text === 'first') {
return await new Promise<SubtitleData | null>((resolve) => {
firstResolve = resolve;
});
}
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('first');
controller.onSubtitleChange('second');
assert.ok(firstResolve);
firstResolve({ text: 'first', tokens: [] });
await flushMicrotasks();
await flushMicrotasks();
assert.deepEqual(emitted, [
{ text: 'first', tokens: null },
{ text: 'second', tokens: null },
{ text: 'second', tokens: [] },
]);
});
test('subtitle processing skips duplicate plain subtitle emission', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('same');
await flushMicrotasks();
controller.onSubtitleChange('same');
await flushMicrotasks();
const plainEmits = emitted.filter((entry) => entry.tokens === null);
assert.equal(plainEmits.length, 1);
assert.equal(tokenizeCalls, 1);
});

View File

@@ -0,0 +1,96 @@
import type { SubtitleData } from '../../types';
export interface SubtitleProcessingControllerDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
emitSubtitle: (payload: SubtitleData) => void;
logDebug?: (message: string) => void;
now?: () => number;
}
export interface SubtitleProcessingController {
onSubtitleChange: (text: string) => void;
}
export function createSubtitleProcessingController(
deps: SubtitleProcessingControllerDeps,
): SubtitleProcessingController {
let latestText = '';
let lastPlainText = '';
let processing = false;
let staleDropCount = 0;
const now = deps.now ?? (() => Date.now());
const emitPlainSubtitle = (text: string): void => {
if (text === lastPlainText) {
return;
}
lastPlainText = text;
deps.emitSubtitle({ text, tokens: null });
};
const processLatest = (): void => {
if (processing) {
return;
}
processing = true;
void (async () => {
while (true) {
const text = latestText;
if (!text.trim()) {
break;
}
const startedAtMs = now();
let tokenized: SubtitleData | null = null;
try {
tokenized = await deps.tokenizeSubtitle(text);
} catch (error) {
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
}
if (latestText !== text) {
staleDropCount += 1;
deps.logDebug?.(
`Dropped stale subtitle tokenization result; dropped=${staleDropCount}, elapsed=${now() - startedAtMs}ms`,
);
continue;
}
if (tokenized) {
deps.emitSubtitle(tokenized);
deps.logDebug?.(
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
);
}
break;
}
})()
.catch((error) => {
deps.logDebug?.(`Subtitle processing loop failed: ${(error as Error).message}`);
})
.finally(() => {
processing = false;
if (latestText !== lastPlainText) {
processLatest();
}
});
};
return {
onSubtitleChange: (text: string) => {
if (text === latestText) {
return;
}
const plainStartedAtMs = now();
latestText = text;
emitPlainSubtitle(text);
deps.logDebug?.(`Subtitle plain emit completed in ${now() - plainStartedAtMs}ms`);
if (!text.trim()) {
return;
}
processLatest();
},
};
}

View File

@@ -90,6 +90,7 @@ import {
createFieldGroupingOverlayRuntime, createFieldGroupingOverlayRuntime,
createNumericShortcutRuntime, createNumericShortcutRuntime,
createOverlayContentMeasurementStore, createOverlayContentMeasurementStore,
createSubtitleProcessingController,
createOverlayWindow as createOverlayWindowCore, createOverlayWindow as createOverlayWindowCore,
createTokenizerDepsRuntime, createTokenizerDepsRuntime,
cycleSecondarySubMode as cycleSecondarySubModeCore, cycleSecondarySubMode as cycleSecondarySubModeCore,
@@ -236,6 +237,8 @@ type ActiveJellyfinRemotePlaybackState = {
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null; let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let jellyfinRemoteLastProgressAtMs = 0; let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null; let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
let backgroundWarmupsStarted = false;
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
function applyJellyfinMpvDefaults(client: MpvIpcClient): void { function applyJellyfinMpvDefaults(client: MpvIpcClient): void {
sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']); sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']);
@@ -364,6 +367,21 @@ const appState = createAppState({
texthookerPort: DEFAULT_TEXTHOOKER_PORT, texthookerPort: DEFAULT_TEXTHOOKER_PORT,
}); });
let appTray: Tray | null = null; let appTray: Tray | null = null;
const subtitleProcessingController = createSubtitleProcessingController({
tokenizeSubtitle: async (text: string) => {
if (getOverlayWindows().length === 0) {
return null;
}
return await tokenizeSubtitle(text);
},
emitSubtitle: (payload) => {
broadcastToOverlayWindows('subtitle:set', payload);
},
logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`);
},
now: () => Date.now(),
});
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
getShortcutsRegistered: () => appState.shortcutsRegistered, getShortcutsRegistered: () => appState.shortcutsRegistered,
@@ -2205,9 +2223,7 @@ const startupState = runStartupBootstrapRuntime(
}, },
log: (message) => appLogger.logInfo(message), log: (message) => appLogger.logInfo(message),
createMecabTokenizerAndCheck: async () => { createMecabTokenizerAndCheck: async () => {
const tokenizer = new MecabTokenizer(); await createMecabTokenizerAndCheck();
appState.mecabTokenizer = tokenizer;
await tokenizer.checkAvailability();
}, },
createSubtitleTimingTracker: () => { createSubtitleTimingTracker: () => {
const tracker = new SubtitleTimingTracker(); const tracker = new SubtitleTimingTracker();
@@ -2258,11 +2274,21 @@ const startupState = runStartupBootstrapRuntime(
startJellyfinRemoteSession: async () => { startJellyfinRemoteSession: async () => {
await startJellyfinRemoteSession(); await startJellyfinRemoteSession();
}, },
prewarmSubtitleDictionaries: async () => {
await prewarmSubtitleDictionaries();
},
startBackgroundWarmups: () => {
startBackgroundWarmups();
},
texthookerOnlyMode: appState.texthookerOnlyMode, texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () => shouldAutoInitializeOverlayRuntimeFromConfig: () =>
appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(), appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(), handleInitialArgs: () => handleInitialArgs(),
logDebug: (message: string) => {
logger.debug(message);
},
now: () => Date.now(),
}), }),
onWillQuitCleanup: () => { onWillQuitCleanup: () => {
destroyTray(); destroyTray();
@@ -2417,12 +2443,7 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
mpvClient.on('subtitle-change', ({ text }) => { mpvClient.on('subtitle-change', ({ text }) => {
appState.currentSubText = text; appState.currentSubText = text;
subtitleWsService.broadcast(text); subtitleWsService.broadcast(text);
void (async () => { subtitleProcessingController.onSubtitleChange(text);
if (getOverlayWindows().length > 0) {
const subtitleData = await tokenizeSubtitle(text);
broadcastToOverlayWindows('subtitle:set', subtitleData);
}
})();
}); });
mpvClient.on('subtitle-ass-change', ({ text }) => { mpvClient.on('subtitle-ass-change', ({ text }) => {
appState.currentSubAssText = text; appState.currentSubAssText = text;
@@ -2548,6 +2569,56 @@ async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
); );
} }
async function createMecabTokenizerAndCheck(): Promise<void> {
if (!appState.mecabTokenizer) {
appState.mecabTokenizer = new MecabTokenizer();
}
await appState.mecabTokenizer.checkAvailability();
}
async function prewarmSubtitleDictionaries(): Promise<void> {
await Promise.all([
jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
]);
}
function launchBackgroundWarmupTask(label: string, task: () => Promise<void>): void {
const startedAtMs = Date.now();
void task()
.then(() => {
logger.debug(`[startup-warmup] ${label} completed in ${Date.now() - startedAtMs}ms`);
})
.catch((error) => {
logger.warn(`[startup-warmup] ${label} failed: ${(error as Error).message}`);
});
}
function startBackgroundWarmups(): void {
if (backgroundWarmupsStarted) {
return;
}
if (appState.texthookerOnlyMode) {
return;
}
backgroundWarmupsStarted = true;
launchBackgroundWarmupTask('mecab', async () => {
await createMecabTokenizerAndCheck();
});
launchBackgroundWarmupTask('yomitan-extension', async () => {
await ensureYomitanExtensionLoaded();
});
launchBackgroundWarmupTask('subtitle-dictionaries', async () => {
await prewarmSubtitleDictionaries();
});
if (getResolvedConfig().jellyfin.remoteControlAutoConnect) {
launchBackgroundWarmupTask('jellyfin-remote-session', async () => {
await startJellyfinRemoteSession();
});
}
}
function updateVisibleOverlayBounds(geometry: WindowGeometry): void { function updateVisibleOverlayBounds(geometry: WindowGeometry): void {
overlayManager.setOverlayWindowBounds('visible', geometry); overlayManager.setOverlayWindowBounds('visible', geometry);
} }
@@ -2589,6 +2660,20 @@ async function loadYomitanExtension(): Promise<Extension | null> {
}); });
} }
async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
if (appState.yomitanExt) {
return appState.yomitanExt;
}
if (yomitanLoadInFlight) {
return yomitanLoadInFlight;
}
yomitanLoadInFlight = loadYomitanExtension().finally(() => {
yomitanLoadInFlight = null;
});
return yomitanLoadInFlight;
}
function createOverlayWindow(kind: 'visible' | 'invisible'): BrowserWindow { function createOverlayWindow(kind: 'visible' | 'invisible'): BrowserWindow {
return createOverlayWindowCore(kind, { return createOverlayWindowCore(kind, {
isDev, isDev,
@@ -2769,15 +2854,26 @@ function initializeOverlayRuntime(): void {
}); });
overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible); overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
appState.overlayRuntimeInitialized = true; appState.overlayRuntimeInitialized = true;
startBackgroundWarmups();
} }
function openYomitanSettings(): void { function openYomitanSettings(): void {
openYomitanSettingsWindow({ void (async () => {
yomitanExt: appState.yomitanExt, const extension = await ensureYomitanExtensionLoaded();
getExistingWindow: () => appState.yomitanSettingsWindow, if (!extension) {
setWindow: (window: BrowserWindow | null) => { logger.warn('Unable to open Yomitan settings: extension failed to load.');
appState.yomitanSettingsWindow = window; return;
}, }
openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: () => appState.yomitanSettingsWindow,
setWindow: (window: BrowserWindow | null) => {
appState.yomitanSettingsWindow = window;
},
});
})().catch((error) => {
logger.error('Failed to open Yomitan settings window.', error);
}); });
} }
function registerGlobalShortcuts(): void { function registerGlobalShortcuts(): void {
@@ -3108,6 +3204,7 @@ registerIpcRuntimeServices({
quitApp: () => app.quit(), quitApp: () => app.quit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText, getCurrentSubtitleAss: () => appState.currentSubAssText,
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics, getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getSubtitlePosition: () => loadSubtitlePosition(), getSubtitlePosition: () => loadSubtitlePosition(),

View File

@@ -39,10 +39,14 @@ export interface AppReadyRuntimeDepsFactoryInput {
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker']; createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession']; startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension']; loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode']; texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig']; shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime']; initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs']; handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
logDebug?: AppReadyRuntimeDeps['logDebug'];
now?: AppReadyRuntimeDeps['now'];
} }
export function createAppLifecycleRuntimeDeps( export function createAppLifecycleRuntimeDeps(
@@ -88,11 +92,15 @@ export function createAppReadyRuntimeDeps(
createImmersionTracker: params.createImmersionTracker, createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession, startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension, loadYomitanExtension: params.loadYomitanExtension,
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
startBackgroundWarmups: params.startBackgroundWarmups,
texthookerOnlyMode: params.texthookerOnlyMode, texthookerOnlyMode: params.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: shouldAutoInitializeOverlayRuntimeFromConfig:
params.shouldAutoInitializeOverlayRuntimeFromConfig, params.shouldAutoInitializeOverlayRuntimeFromConfig,
initializeOverlayRuntime: params.initializeOverlayRuntime, initializeOverlayRuntime: params.initializeOverlayRuntime,
handleInitialArgs: params.handleInitialArgs, handleInitialArgs: params.handleInitialArgs,
logDebug: params.logDebug,
now: params.now,
}; };
} }

View File

@@ -68,6 +68,7 @@ export interface MainIpcRuntimeServiceDepsParams {
quitApp: IpcDepsRuntimeOptions['quitApp']; quitApp: IpcDepsRuntimeOptions['quitApp'];
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay']; toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle']; tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss']; getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow']; focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics']; getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics'];
@@ -205,6 +206,7 @@ export function createMainIpcRuntimeServiceDeps(
quitApp: params.quitApp, quitApp: params.quitApp,
toggleVisibleOverlay: params.toggleVisibleOverlay, toggleVisibleOverlay: params.toggleVisibleOverlay,
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle, tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss, getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics, getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics,
getSubtitlePosition: params.getSubtitlePosition, getSubtitlePosition: params.getSubtitlePosition,

View File

@@ -83,6 +83,7 @@ const electronAPI: ElectronAPI = {
getOverlayVisibility: (): Promise<boolean> => ipcRenderer.invoke('get-overlay-visibility'), getOverlayVisibility: (): Promise<boolean> => ipcRenderer.invoke('get-overlay-visibility'),
getCurrentSubtitle: (): Promise<SubtitleData> => ipcRenderer.invoke('get-current-subtitle'), getCurrentSubtitle: (): Promise<SubtitleData> => ipcRenderer.invoke('get-current-subtitle'),
getCurrentSubtitleRaw: (): Promise<string> => ipcRenderer.invoke('get-current-subtitle-raw'),
getCurrentSubtitleAss: (): Promise<string> => ipcRenderer.invoke('get-current-subtitle-ass'), getCurrentSubtitleAss: (): Promise<string> => ipcRenderer.invoke('get-current-subtitle-ass'),
getMpvSubtitleRenderMetrics: () => ipcRenderer.invoke('get-mpv-subtitle-render-metrics'), getMpvSubtitleRenderMetrics: () => ipcRenderer.invoke('get-mpv-subtitle-render-metrics'),
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => { onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => {

View File

@@ -153,7 +153,7 @@ async function init(): Promise<void> {
}); });
} }
const initialSubtitle = await window.electronAPI.getCurrentSubtitle(); const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
subtitleRenderer.renderSubtitle(initialSubtitle); subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule(); measurementReporter.schedule();

View File

@@ -724,6 +724,7 @@ export interface ElectronAPI {
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
getOverlayVisibility: () => Promise<boolean>; getOverlayVisibility: () => Promise<boolean>;
getCurrentSubtitle: () => Promise<SubtitleData>; getCurrentSubtitle: () => Promise<SubtitleData>;
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>; getCurrentSubtitleAss: () => Promise<string>;
getMpvSubtitleRenderMetrics: () => Promise<MpvSubtitleRenderMetrics>; getMpvSubtitleRenderMetrics: () => Promise<MpvSubtitleRenderMetrics>;
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => void; onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => void;

View File

@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseX11WindowGeometry, parseX11WindowPid, X11WindowTracker } from './x11-tracker';
test('parseX11WindowGeometry parses xwininfo output', () => {
const geometry = parseX11WindowGeometry(`
Absolute upper-left X: 120
Absolute upper-left Y: 240
Width: 1280
Height: 720
`);
assert.deepEqual(geometry, {
x: 120,
y: 240,
width: 1280,
height: 720,
});
});
test('parseX11WindowPid parses xprop output', () => {
assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = 4242'), 4242);
assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = not-a-number'), null);
});
test('X11WindowTracker skips overlapping polls while one command is in flight', async () => {
let commandCalls = 0;
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const tracker = new X11WindowTracker(undefined, async (command) => {
commandCalls += 1;
if (command === 'xdotool') {
await gate;
return '123';
}
if (command === 'xwininfo') {
return `Absolute upper-left X: 0
Absolute upper-left Y: 0
Width: 640
Height: 360`;
}
return '';
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(commandCalls, 1);
assert.ok(release);
release();
await new Promise((resolve) => setTimeout(resolve, 0));
});

View File

@@ -16,20 +16,69 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { execSync } from 'child_process'; import { execFile } from 'child_process';
import { BaseWindowTracker } from './base-tracker'; import { BaseWindowTracker } from './base-tracker';
type CommandRunner = (command: string, args: string[]) => Promise<string>;
function execFileUtf8(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { encoding: 'utf-8' }, (error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
});
});
}
export function parseX11WindowGeometry(winInfo: string): {
x: number;
y: number;
width: number;
height: number;
} | null {
const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/);
const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/);
const widthMatch = winInfo.match(/Width:\s*(\d+)/);
const heightMatch = winInfo.match(/Height:\s*(\d+)/);
if (!xMatch || !yMatch || !widthMatch || !heightMatch) {
return null;
}
return {
x: parseInt(xMatch[1], 10),
y: parseInt(yMatch[1], 10),
width: parseInt(widthMatch[1], 10),
height: parseInt(heightMatch[1], 10),
};
}
export function parseX11WindowPid(raw: string): number | null {
const pidMatch = raw.match(/= (\d+)/);
if (!pidMatch) {
return null;
}
const pid = Number.parseInt(pidMatch[1], 10);
return Number.isInteger(pid) ? pid : null;
}
export class X11WindowTracker extends BaseWindowTracker { export class X11WindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null; private pollInterval: ReturnType<typeof setInterval> | null = null;
private readonly targetMpvSocketPath: string | null; private readonly targetMpvSocketPath: string | null;
private readonly runCommand: CommandRunner;
private pollInFlight = false;
private currentPollIntervalMs = 750;
private readonly stablePollIntervalMs = 250;
constructor(targetMpvSocketPath?: string) { constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileUtf8) {
super(); super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.runCommand = runCommand;
} }
start(): void { start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250); this.resetPollInterval(this.currentPollIntervalMs);
this.pollGeometry(); this.pollGeometry();
} }
@@ -40,60 +89,69 @@ export class X11WindowTracker extends BaseWindowTracker {
} }
} }
private resetPollInterval(intervalMs: number): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
this.pollInterval = setInterval(() => this.pollGeometry(), intervalMs);
}
private pollGeometry(): void { private pollGeometry(): void {
try { if (this.pollInFlight) {
const windowIds = execSync('xdotool search --class mpv', { return;
encoding: 'utf-8', }
}).trim(); this.pollInFlight = true;
void this.pollGeometryAsync()
if (!windowIds) { .catch(() => {
this.updateGeometry(null); this.updateGeometry(null);
return; })
} .finally(() => {
this.pollInFlight = false;
const windowIdList = windowIds.split(/\s+/).filter(Boolean);
if (windowIdList.length === 0) {
this.updateGeometry(null);
return;
}
const windowId = this.findTargetWindowId(windowIdList);
if (!windowId) {
this.updateGeometry(null);
return;
}
const winInfo = execSync(`xwininfo -id ${windowId}`, {
encoding: 'utf-8',
}); });
}
const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/); private async pollGeometryAsync(): Promise<void> {
const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/); const windowIdsOutput = await this.runCommand('xdotool', ['search', '--class', 'mpv']);
const widthMatch = winInfo.match(/Width:\s*(\d+)/); const windowIds = windowIdsOutput.trim();
const heightMatch = winInfo.match(/Height:\s*(\d+)/); if (!windowIds) {
if (xMatch && yMatch && widthMatch && heightMatch) {
this.updateGeometry({
x: parseInt(xMatch[1], 10),
y: parseInt(yMatch[1], 10),
width: parseInt(widthMatch[1], 10),
height: parseInt(heightMatch[1], 10),
});
} else {
this.updateGeometry(null);
}
} catch (err) {
this.updateGeometry(null); this.updateGeometry(null);
return;
}
const windowIdList = windowIds.split(/\s+/).filter(Boolean);
if (windowIdList.length === 0) {
this.updateGeometry(null);
return;
}
const windowId = await this.findTargetWindowId(windowIdList);
if (!windowId) {
this.updateGeometry(null);
return;
}
const winInfo = await this.runCommand('xwininfo', ['-id', windowId]);
const geometry = parseX11WindowGeometry(winInfo);
if (!geometry) {
this.updateGeometry(null);
return;
}
this.updateGeometry(geometry);
if (this.pollInterval && this.currentPollIntervalMs !== this.stablePollIntervalMs) {
this.currentPollIntervalMs = this.stablePollIntervalMs;
this.resetPollInterval(this.currentPollIntervalMs);
} }
} }
private findTargetWindowId(windowIds: string[]): string | null { private async findTargetWindowId(windowIds: string[]): Promise<string | null> {
if (!this.targetMpvSocketPath) { if (!this.targetMpvSocketPath) {
return windowIds[0] ?? null; return windowIds[0] ?? null;
} }
for (const windowId of windowIds) { for (const windowId of windowIds) {
if (this.isWindowForTargetSocket(windowId)) { if (await this.isWindowForTargetSocket(windowId)) {
return windowId; return windowId;
} }
} }
@@ -101,13 +159,13 @@ export class X11WindowTracker extends BaseWindowTracker {
return null; return null;
} }
private isWindowForTargetSocket(windowId: string): boolean { private async isWindowForTargetSocket(windowId: string): Promise<boolean> {
const pid = this.getWindowPid(windowId); const pid = await this.getWindowPid(windowId);
if (pid === null) { if (pid === null) {
return false; return false;
} }
const commandLine = this.getWindowCommandLine(pid); const commandLine = await this.getWindowCommandLine(pid);
if (!commandLine) { if (!commandLine) {
return false; return false;
} }
@@ -118,23 +176,24 @@ export class X11WindowTracker extends BaseWindowTracker {
); );
} }
private getWindowPid(windowId: string): number | null { private async getWindowPid(windowId: string): Promise<number | null> {
const windowPid = execSync(`xprop -id ${windowId} _NET_WM_PID`, { let windowPid: string;
encoding: 'utf-8', try {
}); windowPid = await this.runCommand('xprop', ['-id', windowId, '_NET_WM_PID']);
const pidMatch = windowPid.match(/= (\d+)/); } catch {
if (!pidMatch) {
return null; return null;
} }
return parseX11WindowPid(windowPid);
const pid = Number.parseInt(pidMatch[1], 10);
return Number.isInteger(pid) ? pid : null;
} }
private getWindowCommandLine(pid: number): string | null { private async getWindowCommandLine(pid: number): Promise<string | null> {
const commandLine = execSync(`ps -p ${pid} -o args=`, { let raw: string;
encoding: 'utf-8', try {
}).trim(); raw = await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']);
} catch {
return null;
}
const commandLine = raw.trim();
return commandLine || null; return commandLine || null;
} }
} }