mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-13 03:13:32 -07:00
refactor(main): split main.ts into focused runtime modules (#123)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
type: internal
|
||||||
|
area: runtime
|
||||||
|
|
||||||
|
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
|
||||||
|
- Hardened split runtime helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -45,6 +45,7 @@ export interface MpvRuntimeClientLike {
|
|||||||
playNextSubtitle?: () => void;
|
playNextSubtitle?: () => void;
|
||||||
setSubVisibility?: (visible: boolean) => void;
|
setSubVisibility?: (visible: boolean) => void;
|
||||||
setSecondarySubVisibility?: (visible: boolean) => void;
|
setSecondarySubVisibility?: (visible: boolean) => void;
|
||||||
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showMpvOsdRuntime(
|
export function showMpvOsdRuntime(
|
||||||
|
|||||||
+337
-1974
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,10 @@ function readMainSource(): string {
|
|||||||
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
|
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSource(relPath: string): string {
|
||||||
|
return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
test('manual watched session action starts immersion tracker before marking watched', () => {
|
test('manual watched session action starts immersion tracker before marking watched', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
@@ -91,15 +95,15 @@ test('mpv startup signals start overlay loading OSD before readiness work', () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
|
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||||
const dismissBlock = source.match(
|
const dismissBlock = source.match(
|
||||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(dismissBlock);
|
assert.ok(dismissBlock);
|
||||||
assert.match(
|
assert.match(
|
||||||
dismissBlock,
|
dismissBlock,
|
||||||
/sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/,
|
/sendMpvCommandRuntime\(deps\.getMpvClient\(\), \[\s*'script-message',\s*'subminer-overlay-loading-ready',\s*\]\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,9 +150,9 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n\}/,
|
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -157,13 +161,14 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
|||||||
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') <
|
actionBlock.indexOf('deps.initSubtitlePrefetch(') <
|
||||||
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'),
|
actionBlock.indexOf('deps.setActiveParsedSubtitleMediaPath(nextMediaPath);'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update overlay notification action triggers install flow', () => {
|
test('update overlay notification action triggers install flow', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
|
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
@@ -173,13 +178,16 @@ test('update overlay notification action triggers install flow', () => {
|
|||||||
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
||||||
assert.match(source, /installWhenAvailable:\s*true/);
|
assert.match(source, /installWhenAvailable:\s*true/);
|
||||||
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
||||||
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/);
|
assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/);
|
||||||
assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
runtimeSource,
|
||||||
|
/deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
runtimeSource,
|
||||||
/new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/,
|
/new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/,
|
||||||
);
|
);
|
||||||
assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
|
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
|
||||||
@@ -203,9 +211,9 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
|
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -346,18 +354,18 @@ test('warm tokenization release can signal readiness before the first subtitle a
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/stats-server-runtime.ts');
|
||||||
const startStatsServerBlock = source.match(
|
const startStatsServerBlock = source.match(
|
||||||
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
||||||
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(addYomitanNoteBlock);
|
assert.ok(addYomitanNoteBlock);
|
||||||
assert.match(
|
assert.match(
|
||||||
addYomitanNoteBlock,
|
addYomitanNoteBlock,
|
||||||
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/,
|
/const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/,
|
||||||
);
|
);
|
||||||
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||||
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||||
@@ -365,11 +373,12 @@ test('stats server Yomitan note creation honors configured Anki server override
|
|||||||
|
|
||||||
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
|
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const resetBlock = source.match(
|
const resetBlock = runtimeSource.match(
|
||||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -459,17 +468,17 @@ test('manual visible overlay hide dismisses loading OSD', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('configured overlay notifications require visible ready overlay window', () => {
|
test('configured overlay notifications require visible ready overlay window', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||||
const readinessBlock = source.match(
|
const readinessBlock = source.match(
|
||||||
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const statusBlock = source.match(
|
const statusBlock = source.match(
|
||||||
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(readinessBlock);
|
assert.ok(readinessBlock);
|
||||||
assert.ok(statusBlock);
|
assert.ok(statusBlock);
|
||||||
assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/);
|
assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/);
|
||||||
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
|
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
|
||||||
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
|
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
|
||||||
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
||||||
@@ -498,8 +507,9 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
|
|||||||
|
|
||||||
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
|
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const resetBlock = source.match(
|
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
const resetBlock = runtimeSource.match(
|
||||||
|
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const setBlock = source.match(
|
const setBlock = source.match(
|
||||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
@@ -509,6 +519,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
|
|||||||
assert.ok(setBlock);
|
assert.ok(setBlock);
|
||||||
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||||
|
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
setBlock,
|
setBlock,
|
||||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||||
@@ -516,9 +527,9 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
|
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/overlay-geometry-runtime.ts');
|
||||||
const afterBoundsBlock = source.match(
|
const afterBoundsBlock = source.match(
|
||||||
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(afterBoundsBlock);
|
assert.ok(afterBoundsBlock);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getPasswordStoreArg } from './password-store-args';
|
||||||
|
|
||||||
|
test('getPasswordStoreArg ignores split-form whitespace-only values', () => {
|
||||||
|
assert.equal(getPasswordStoreArg(['SubMiner.AppImage', '--password-store', ' ']), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const PASSWORD_STORE_ARG = '--password-store';
|
||||||
|
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
|
||||||
|
|
||||||
|
export function getPasswordStoreArg(argv: string[]): string | null {
|
||||||
|
let resolved: string | null = null;
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (!arg?.startsWith(PASSWORD_STORE_ARG)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === PASSWORD_STORE_ARG) {
|
||||||
|
const value = argv[i + 1];
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith('--')) {
|
||||||
|
resolved = trimmed;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [prefix, value] = arg.split('=', 2);
|
||||||
|
if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) {
|
||||||
|
resolved = value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePasswordStoreArg(value: string): string {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized.toLowerCase() === 'gnome') {
|
||||||
|
return DEFAULT_LINUX_PASSWORD_STORE;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPasswordStore(): string {
|
||||||
|
return DEFAULT_LINUX_PASSWORD_STORE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createAutoplaySubtitlePrimingRuntime,
|
||||||
|
setMpvCurrentSecondarySubText,
|
||||||
|
} from './autoplay-subtitle-priming-runtime';
|
||||||
|
|
||||||
|
test('setMpvCurrentSecondarySubText uses client setter when available', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const client = {
|
||||||
|
currentSecondarySubText: '',
|
||||||
|
setCurrentSecondarySubText: (text: string) => {
|
||||||
|
calls.push(text);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setMpvCurrentSecondarySubText(client, 'secondary');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['secondary']);
|
||||||
|
assert.equal(client.currentSecondarySubText, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setMpvCurrentSecondarySubText updates client property when setter is unavailable', () => {
|
||||||
|
const client = {
|
||||||
|
currentSecondarySubText: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
setMpvCurrentSecondarySubText(client, 'secondary');
|
||||||
|
|
||||||
|
assert.equal(client.currentSecondarySubText, 'secondary');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback', async () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const runtime = createAutoplaySubtitlePrimingRuntime({
|
||||||
|
getCurrentMediaPath: () => null,
|
||||||
|
getMpvClient: () => null,
|
||||||
|
setCurrentSubText: () => {},
|
||||||
|
getCurrentSubText: () => '',
|
||||||
|
getCurrentSubtitleData: () => null,
|
||||||
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
|
subtitleProcessingController: {
|
||||||
|
consumeCachedSubtitle: () => null,
|
||||||
|
onSubtitleChange: () => {},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
},
|
||||||
|
emitSubtitlePayload: () => {},
|
||||||
|
getSubtitlePrefetchService: () => null,
|
||||||
|
getLastObservedTimePos: () => 0,
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
emitSecondarySubtitle: () => {},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
refreshSubtitlePrefetchFromActiveTrack: async () => {
|
||||||
|
throw new Error('refresh failed');
|
||||||
|
},
|
||||||
|
logDebug: (message) => logs.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.scheduleSubtitlePrefetchRefresh(0);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
assert.deepEqual(logs, [
|
||||||
|
'[autoplay-subtitle-prime] subtitle prefetch refresh failed: refresh failed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import type { SubtitleCue, SubtitleData } from '../../types';
|
||||||
|
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||||
|
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
||||||
|
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||||
|
|
||||||
|
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
||||||
|
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
|
||||||
|
|
||||||
|
type AutoplaySubtitlePrimingMpvClient = {
|
||||||
|
connected?: boolean;
|
||||||
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
|
currentVideoPath?: string;
|
||||||
|
currentTimePos?: number;
|
||||||
|
currentSecondarySubText?: string;
|
||||||
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoplaySubtitlePrimingPrefetchService = {
|
||||||
|
pause: () => void;
|
||||||
|
onSeek: (timePos: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AutoplaySubtitlePrimingRuntimeDeps {
|
||||||
|
getCurrentMediaPath: () => string | null | undefined;
|
||||||
|
getMpvClient: () => AutoplaySubtitlePrimingMpvClient | null;
|
||||||
|
setCurrentSubText: (text: string) => void;
|
||||||
|
getCurrentSubText: () => string;
|
||||||
|
getCurrentSubtitleData: () => SubtitleData | null;
|
||||||
|
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
|
||||||
|
subtitleProcessingController: {
|
||||||
|
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||||
|
onSubtitleChange: (text: string) => void;
|
||||||
|
refreshCurrentSubtitle: (text: string) => void;
|
||||||
|
};
|
||||||
|
emitSubtitlePayload: (payload: SubtitleData) => void;
|
||||||
|
getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null;
|
||||||
|
getLastObservedTimePos: () => number;
|
||||||
|
getVisibleOverlayVisible: () => boolean;
|
||||||
|
emitSecondarySubtitle: (text: string) => void;
|
||||||
|
initSubtitlePrefetch: (
|
||||||
|
sourcePath: string,
|
||||||
|
currentTimePos: number,
|
||||||
|
sourceKey?: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMpvCurrentSecondarySubText(
|
||||||
|
client: Pick<
|
||||||
|
AutoplaySubtitlePrimingMpvClient,
|
||||||
|
'currentSecondarySubText' | 'setCurrentSecondarySubText'
|
||||||
|
>,
|
||||||
|
text: string,
|
||||||
|
): void {
|
||||||
|
if (typeof client.setCurrentSecondarySubText === 'function') {
|
||||||
|
client.setCurrentSecondarySubText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.currentSecondarySubText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
|
||||||
|
const { subtitleProcessingController, emitSubtitlePayload } = deps;
|
||||||
|
|
||||||
|
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
||||||
|
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
function getCurrentAutoplayMediaPath(): string | null {
|
||||||
|
return (
|
||||||
|
deps.getCurrentMediaPath()?.trim() || deps.getMpvClient()?.currentVideoPath?.trim() || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentAutoplayMediaPath(mediaPath: string): boolean {
|
||||||
|
return getCurrentAutoplayMediaPath() === mediaPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean {
|
||||||
|
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
autoplaySubtitlePrimedMediaPath = mediaPath;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAutoplaySubtitlePrime(): void {
|
||||||
|
autoplaySubtitlePrimedMediaPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
|
||||||
|
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.setCurrentSubText(text);
|
||||||
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
|
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
|
||||||
|
if (cachedPayload) {
|
||||||
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
|
emitSubtitlePayload(cachedPayload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subTextRaw = await client.requestProperty('sub-text').catch((error) => {
|
||||||
|
deps.logDebug(
|
||||||
|
`[autoplay-subtitle-prime] failed to read sub-text: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||||
|
emitAutoplayPrimedSubtitle(mediaPath, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||||
|
await primeVisibleOverlaySubtitleFromMpv({
|
||||||
|
getMpvClient: () => deps.getMpvClient(),
|
||||||
|
setCurrentSubText: (text) => {
|
||||||
|
deps.setCurrentSubText(text);
|
||||||
|
},
|
||||||
|
getCurrentSubtitleData: () => deps.getCurrentSubtitleData(),
|
||||||
|
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
|
||||||
|
onSubtitleChange: (text) => {
|
||||||
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||||
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: (text) => {
|
||||||
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||||
|
},
|
||||||
|
deferUncachedRefresh: true,
|
||||||
|
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||||
|
setCurrentSecondarySubText: (text) => {
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
if (client) {
|
||||||
|
setMpvCurrentSecondarySubText(client, text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emitSecondarySubtitle: (text) => {
|
||||||
|
deps.emitSecondarySubtitle(text);
|
||||||
|
},
|
||||||
|
logDebug: (message) => {
|
||||||
|
deps.logDebug(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||||
|
if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer);
|
||||||
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||||
|
if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!deps.getVisibleOverlayVisible() || !deps.getCurrentSubText().trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => {
|
||||||
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||||
|
if (!deps.getVisibleOverlayVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = deps.getCurrentSubText();
|
||||||
|
if (!text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||||
|
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
|
||||||
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function primeAutoplaySubtitleFromParsedCues(
|
||||||
|
mediaPath: string,
|
||||||
|
cues: SubtitleCue[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
cues.length === 0 ||
|
||||||
|
autoplaySubtitlePrimedMediaPath === mediaPath ||
|
||||||
|
!isCurrentAutoplayMediaPath(mediaPath)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
const timePosRaw = await client?.requestProperty('time-pos').catch(() => null);
|
||||||
|
const currentTimeSeconds = Number(
|
||||||
|
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||||
|
);
|
||||||
|
const cue = selectAutoplayStartupCue(
|
||||||
|
cues,
|
||||||
|
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||||
|
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
||||||
|
);
|
||||||
|
if (!cue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||||
|
if (subtitlePrefetchRefreshTimer) {
|
||||||
|
clearTimeout(subtitlePrefetchRefreshTimer);
|
||||||
|
subtitlePrefetchRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSubtitleSidebarFromSource(
|
||||||
|
sourcePath: string,
|
||||||
|
mediaPath?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
|
||||||
|
if (!normalizedSourcePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||||
|
await deps.initSubtitlePrefetch(
|
||||||
|
normalizedSourcePath,
|
||||||
|
deps.getLastObservedTimePos(),
|
||||||
|
normalizedSourcePath,
|
||||||
|
);
|
||||||
|
deps.setActiveParsedSubtitleMediaPath(nextMediaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||||
|
clearScheduledSubtitlePrefetchRefresh();
|
||||||
|
subtitlePrefetchRefreshTimer = setTimeout(() => {
|
||||||
|
subtitlePrefetchRefreshTimer = null;
|
||||||
|
void deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => {
|
||||||
|
deps.logDebug(
|
||||||
|
`[autoplay-subtitle-prime] subtitle prefetch refresh failed: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentAutoplayMediaPath,
|
||||||
|
resetAutoplaySubtitlePrime,
|
||||||
|
primeCurrentSubtitleForAutoplay,
|
||||||
|
primeCurrentSubtitleForVisibleOverlay,
|
||||||
|
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
||||||
|
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
||||||
|
primeAutoplaySubtitleFromParsedCues,
|
||||||
|
clearScheduledSubtitlePrefetchRefresh,
|
||||||
|
refreshSubtitleSidebarFromSource,
|
||||||
|
scheduleSubtitlePrefetchRefresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -180,6 +180,21 @@ export function detectInstalledFirstRunPluginCandidates(options: {
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detectWindowsMpvPluginRemovalCandidates(options: {
|
||||||
|
homeDir: string;
|
||||||
|
appDataDir: string;
|
||||||
|
mpvExecutablePath: string;
|
||||||
|
existsSync?: (candidate: string) => boolean;
|
||||||
|
}): InstalledFirstRunPluginCandidate[] {
|
||||||
|
return detectInstalledFirstRunPluginCandidates({
|
||||||
|
platform: 'win32',
|
||||||
|
homeDir: options.homeDir,
|
||||||
|
appDataDir: options.appDataDir,
|
||||||
|
mpvExecutablePath: options.mpvExecutablePath,
|
||||||
|
existsSync: options.existsSync,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function parseInstalledPluginVersion(content: string): string | null {
|
function parseInstalledPluginVersion(content: string): string | null {
|
||||||
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
|
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
|
||||||
return match?.[1] ?? null;
|
return match?.[1] ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildFfmpegSubtitleExtractionArgs,
|
||||||
|
extractInternalSubtitleTrackToTempFile,
|
||||||
|
parseTrackId,
|
||||||
|
} from './internal-subtitle-extraction';
|
||||||
|
|
||||||
|
test('buildFfmpegSubtitleExtractionArgs rejects output paths without an extension', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => buildFfmpegSubtitleExtractionArgs('/tmp/video.mkv', 2, '/tmp/subtitle-output'),
|
||||||
|
/outputPath.*file extension/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseTrackId rejects negative track ids', () => {
|
||||||
|
assert.equal(parseTrackId(-1), null);
|
||||||
|
assert.equal(parseTrackId(' -2 '), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-'));
|
||||||
|
const videoPath = path.join(root, 'video.mkv');
|
||||||
|
fs.writeFileSync(videoPath, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
extractInternalSubtitleTrackToTempFile(
|
||||||
|
process.execPath,
|
||||||
|
videoPath,
|
||||||
|
{ 'ff-index': 0, codec: 'ass' },
|
||||||
|
{
|
||||||
|
extractionTimeoutMs: 20,
|
||||||
|
spawnArgsOverride: ['-e', 'setTimeout(() => {}, 1000);'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/ffmpeg extraction timed out/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||||
|
import { codecToExtension } from '../../subsync/utils';
|
||||||
|
|
||||||
|
export async function loadSubtitleSourceText(source: string): Promise<string> {
|
||||||
|
if (/^https?:\/\//i.test(source)) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(source, { signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download subtitle source (${response.status})`);
|
||||||
|
}
|
||||||
|
return await response.text();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = resolveSubtitleSourcePath(source);
|
||||||
|
return fs.promises.readFile(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MpvSubtitleTrackLike = {
|
||||||
|
type?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
selected?: unknown;
|
||||||
|
external?: unknown;
|
||||||
|
codec?: unknown;
|
||||||
|
'ff-index'?: unknown;
|
||||||
|
'external-filename'?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export function parseTrackId(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFfmpegSubtitleExtractionArgs(
|
||||||
|
videoPath: string,
|
||||||
|
ffIndex: number,
|
||||||
|
outputPath: string,
|
||||||
|
): string[] {
|
||||||
|
const outputFormat = path.extname(outputPath).slice(1);
|
||||||
|
if (!outputFormat) {
|
||||||
|
throw new Error(`outputPath must include a file extension for ffmpeg format: ${outputPath}`);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'-hide_banner',
|
||||||
|
'-nostdin',
|
||||||
|
'-y',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
'-an',
|
||||||
|
'-vn',
|
||||||
|
'-i',
|
||||||
|
videoPath,
|
||||||
|
'-map',
|
||||||
|
`0:${ffIndex}`,
|
||||||
|
'-f',
|
||||||
|
outputFormat,
|
||||||
|
outputPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractInternalSubtitleTrackToTempFile(
|
||||||
|
ffmpegPath: string,
|
||||||
|
videoPath: string,
|
||||||
|
track: MpvSubtitleTrackLike,
|
||||||
|
options: { extractionTimeoutMs?: number; spawnArgsOverride?: string[] } = {},
|
||||||
|
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
|
||||||
|
const ffIndex = parseTrackId(track['ff-index']);
|
||||||
|
const codec = typeof track.codec === 'string' ? track.codec : null;
|
||||||
|
const extension = codecToExtension(codec ?? undefined);
|
||||||
|
if (ffIndex === null || extension === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
|
||||||
|
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const child = spawn(
|
||||||
|
ffmpegPath,
|
||||||
|
options.spawnArgsOverride ??
|
||||||
|
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
|
||||||
|
);
|
||||||
|
const extractionTimeoutMs = options.extractionTimeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error(`ffmpeg extraction timed out after ${extractionTimeoutMs}ms`));
|
||||||
|
}, extractionTimeoutMs);
|
||||||
|
const settle = (callback: () => void): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on('error', (error) => {
|
||||||
|
settle(() => reject(error));
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
settle(() => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: outputPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { showLogExportErrorDialog, showLogExportSuccessDialog } from './log-export-dialogs';
|
||||||
|
|
||||||
|
test('showLogExportSuccessDialog handles dialog rejection', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
await showLogExportSuccessDialog({
|
||||||
|
zipPath: '/tmp/subminer-logs.zip',
|
||||||
|
showMessageBox: async () => {
|
||||||
|
throw new Error('dialog failed');
|
||||||
|
},
|
||||||
|
showItemInFolder: () => {
|
||||||
|
throw new Error('unexpected shell call');
|
||||||
|
},
|
||||||
|
logWarn: (message) => warnings.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(warnings, ['Failed to show log export success dialog.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showLogExportErrorDialog handles dialog rejection', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
await showLogExportErrorDialog({
|
||||||
|
message: 'export failed',
|
||||||
|
showMessageBox: async () => {
|
||||||
|
throw new Error('dialog failed');
|
||||||
|
},
|
||||||
|
logWarn: (message) => warnings.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(warnings, ['Failed to show log export error dialog.']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron';
|
||||||
|
|
||||||
|
type ShowMessageBox = (options: MessageBoxOptions) => Promise<MessageBoxReturnValue>;
|
||||||
|
|
||||||
|
export async function showLogExportSuccessDialog(options: {
|
||||||
|
zipPath: string;
|
||||||
|
showMessageBox: ShowMessageBox;
|
||||||
|
showItemInFolder: (path: string) => void;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
const successDialog = await options
|
||||||
|
.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: 'SubMiner logs exported',
|
||||||
|
message: 'SubMiner log export created.',
|
||||||
|
detail: options.zipPath,
|
||||||
|
buttons: ['OK', 'Show in Folder'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
})
|
||||||
|
.catch((dialogError) => {
|
||||||
|
options.logWarn('Failed to show log export success dialog.', dialogError);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (successDialog?.response === 1) {
|
||||||
|
options.showItemInFolder(options.zipPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showLogExportErrorDialog(options: {
|
||||||
|
message: string;
|
||||||
|
showMessageBox: ShowMessageBox;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
await options
|
||||||
|
.showMessageBox({
|
||||||
|
type: 'error',
|
||||||
|
title: 'SubMiner log export failed',
|
||||||
|
message: 'Could not export SubMiner logs.',
|
||||||
|
detail: options.message,
|
||||||
|
})
|
||||||
|
.catch((dialogError) => {
|
||||||
|
options.logWarn('Failed to show log export error dialog.', dialogError);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { app, dialog, shell } from 'electron';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { showLogExportErrorDialog, showLogExportSuccessDialog } from './log-export-dialogs';
|
||||||
|
import { exportLogsArchive } from './log-export';
|
||||||
|
|
||||||
|
export interface LogExportTrayRuntimeDeps {
|
||||||
|
flushMpvLog: () => Promise<void>;
|
||||||
|
logInfo: (message: string) => void;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogExportTrayRuntime(deps: LogExportTrayRuntimeDeps): {
|
||||||
|
exportLogsFromTray: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
function describeUnknownError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportLogsFromTray(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await deps.flushMpvLog();
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('Failed to flush mpv log before exporting logs from tray.', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = exportLogsArchive({
|
||||||
|
platform: process.platform,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
appDataDir: app.getPath('appData'),
|
||||||
|
});
|
||||||
|
deps.logInfo(
|
||||||
|
`Exported ${result.exportedFiles.length} sanitized log file(s) to ${result.zipPath}`,
|
||||||
|
);
|
||||||
|
await showLogExportSuccessDialog({
|
||||||
|
zipPath: result.zipPath,
|
||||||
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
|
showItemInFolder: (zipPath) => shell.showItemInFolder(zipPath),
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = describeUnknownError(error);
|
||||||
|
deps.logWarn('Failed to export logs from tray.', error);
|
||||||
|
await showLogExportErrorDialog({
|
||||||
|
message,
|
||||||
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exportLogsFromTray };
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { type BrowserWindow, screen } from 'electron';
|
||||||
|
import type { WindowGeometry } from '../../types';
|
||||||
|
import { hasHyprlandWindowPlacementBoundsMismatch } from '../../core/services/hyprland-window-placement';
|
||||||
|
import { normalizeOverlayWindowBoundsForPlatform } from '../../core/services/overlay-window-bounds';
|
||||||
|
import {
|
||||||
|
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
|
||||||
|
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
|
||||||
|
syncOverlayWindowLayer,
|
||||||
|
} from '../../core/services/overlay-window';
|
||||||
|
import { promoteStatsOverlayAbovePlayback } from '../../core/services/stats-window.js';
|
||||||
|
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
|
||||||
|
import { shouldRunLinuxOverlayZOrderKeepAlive } from './linux-overlay-zorder-keepalive';
|
||||||
|
import {
|
||||||
|
shouldExitFullscreenOverrideForTrackedGeometry,
|
||||||
|
type LinuxVisibleOverlayWindowMode,
|
||||||
|
} from './linux-visible-overlay-window-mode';
|
||||||
|
import {
|
||||||
|
createEnforceOverlayLayerOrderHandler,
|
||||||
|
createEnsureOverlayWindowLevelHandler,
|
||||||
|
createUpdateVisibleOverlayBoundsHandler,
|
||||||
|
hasLiveOverlayWindowBoundsMismatch,
|
||||||
|
} from './overlay-window-layout';
|
||||||
|
import {
|
||||||
|
createBuildEnforceOverlayLayerOrderMainDepsHandler,
|
||||||
|
createBuildEnsureOverlayWindowLevelMainDepsHandler,
|
||||||
|
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
|
||||||
|
} from './overlay-window-layout-main-deps';
|
||||||
|
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order';
|
||||||
|
|
||||||
|
const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
|
||||||
|
|
||||||
|
export interface OverlayGeometryRuntimeDeps {
|
||||||
|
overlayManager: {
|
||||||
|
getMainWindow: () => BrowserWindow | null;
|
||||||
|
getModalWindow: () => BrowserWindow | null;
|
||||||
|
getVisibleOverlayVisible: () => boolean;
|
||||||
|
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
|
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
|
};
|
||||||
|
getTrackedWindowGeometry: () => WindowGeometry | null;
|
||||||
|
getTrackedWindowMediaSourceId: () => string | null | undefined;
|
||||||
|
getTrackedWindowNativeId: () => string | null | undefined;
|
||||||
|
getStatsOverlayVisible: () => boolean;
|
||||||
|
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
|
||||||
|
getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode;
|
||||||
|
getLinuxTrackedMpvFullscreen: () => boolean;
|
||||||
|
getLinuxTrackedMpvFullscreenChangedAtMs: () => number;
|
||||||
|
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) => void;
|
||||||
|
getLinuxVisibleOverlayOwnerBindingKey: () => string | null;
|
||||||
|
setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void;
|
||||||
|
clearVisibleOverlayX11OwnerBinding: (window: BrowserWindow) => void;
|
||||||
|
getNativeWindowHandleDecimal: (window: BrowserWindow) => string;
|
||||||
|
enqueueVisibleOverlayX11OwnerBindingOperation: (
|
||||||
|
window: BrowserWindow,
|
||||||
|
args: string[],
|
||||||
|
onError?: (error: Error) => void,
|
||||||
|
) => void;
|
||||||
|
scheduleWindowsVisibleOverlayZOrderSyncBurst: () => void;
|
||||||
|
logDebug: (message: string, ...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
|
||||||
|
const { overlayManager } = deps;
|
||||||
|
|
||||||
|
let lastOverlayWindowGeometry: WindowGeometry | null = null;
|
||||||
|
|
||||||
|
function getOverlayGeometryFallback(): WindowGeometry {
|
||||||
|
const cursorPoint = screen.getCursorScreenPoint();
|
||||||
|
const display = screen.getDisplayNearestPoint(cursorPoint);
|
||||||
|
const bounds = display.workArea;
|
||||||
|
return {
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentOverlayGeometry(): WindowGeometry {
|
||||||
|
if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry;
|
||||||
|
const trackerGeometry = deps.getTrackedWindowGeometry();
|
||||||
|
if (trackerGeometry) return trackerGeometry;
|
||||||
|
return getOverlayGeometryFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentTrackedOverlayGeometry(): WindowGeometry | null {
|
||||||
|
return deps.getTrackedWindowGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOverlayRegions(geometry: WindowGeometry): void {
|
||||||
|
lastOverlayWindowGeometry = geometry;
|
||||||
|
maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry);
|
||||||
|
overlayManager.setOverlayWindowBounds(geometry);
|
||||||
|
overlayManager.setModalWindowBounds(geometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldExitLinuxFullscreenOverrideForGeometry(geometry: WindowGeometry): boolean {
|
||||||
|
if (!shouldRunLinuxOverlayZOrderKeepAlive()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
deps.getLinuxTrackedMpvFullscreenChangedAtMs() > 0 &&
|
||||||
|
Date.now() - deps.getLinuxTrackedMpvFullscreenChangedAtMs() <
|
||||||
|
LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayBounds = screen.getDisplayMatching(geometry).bounds;
|
||||||
|
return shouldExitFullscreenOverrideForTrackedGeometry({
|
||||||
|
currentMode: deps.getLinuxVisibleOverlayWindowMode(),
|
||||||
|
trackedFullscreen: deps.getLinuxTrackedMpvFullscreen(),
|
||||||
|
geometry,
|
||||||
|
displayBounds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry: WindowGeometry): void {
|
||||||
|
if (!shouldExitLinuxFullscreenOverrideForGeometry(geometry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.logDebug(
|
||||||
|
'Tracked mpv geometry no longer covers its display; exiting Linux fullscreen overlay override',
|
||||||
|
);
|
||||||
|
deps.syncLinuxVisibleOverlayMpvFullscreenMode(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasHyprlandOverlayWindowPlacementMismatch(geometry: WindowGeometry): boolean {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [overlayManager.getMainWindow(), overlayManager.getModalWindow()].some((window) => {
|
||||||
|
if (!window || window.isDestroyed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hasHyprlandWindowPlacementBoundsMismatch({
|
||||||
|
title: window.getTitle(),
|
||||||
|
bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen, window),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
||||||
|
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||||
|
getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry,
|
||||||
|
shouldRefreshUnchangedGeometry: (geometry) =>
|
||||||
|
shouldExitLinuxFullscreenOverrideForGeometry(geometry) ||
|
||||||
|
(process.platform === 'linux' &&
|
||||||
|
(hasLiveOverlayWindowBoundsMismatch(
|
||||||
|
[overlayManager.getMainWindow(), overlayManager.getModalWindow()],
|
||||||
|
geometry,
|
||||||
|
) ||
|
||||||
|
hasHyprlandOverlayWindowPlacementMismatch(geometry))),
|
||||||
|
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||||
|
afterSetOverlayWindowBounds: () => {
|
||||||
|
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
deps.scheduleWindowsVisibleOverlayZOrderSyncBurst();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
restoreLinuxOverlayWindowShape(mainWindow);
|
||||||
|
}
|
||||||
|
ensureOverlayWindowLevel(mainWindow);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
|
||||||
|
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
||||||
|
updateVisibleOverlayBoundsMainDeps,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildEnsureOverlayWindowLevelMainDepsHandler =
|
||||||
|
createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||||
|
shouldSuppressOverlayWindowLevel: (window) => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
return (
|
||||||
|
(deps.getStatsOverlayVisible() && window === mainWindow) ||
|
||||||
|
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
|
||||||
|
window,
|
||||||
|
mainWindow,
|
||||||
|
separateWindows: deps.getOverlayForegroundSeparateWindows(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevelCore: (window) =>
|
||||||
|
ensureOverlayWindowLevelCore(window as BrowserWindow),
|
||||||
|
afterEnsureOverlayWindowLevel: () => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow);
|
||||||
|
}
|
||||||
|
promoteStatsOverlayAbovePlayback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler();
|
||||||
|
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
|
||||||
|
ensureOverlayWindowLevelMainDeps,
|
||||||
|
);
|
||||||
|
|
||||||
|
function syncPrimaryOverlayWindowLayer(layer: 'visible'): void {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
syncOverlayWindowLayer(mainWindow, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveVisibleOverlayAboveTrackedPlaybackWindow(window: BrowserWindow): void {
|
||||||
|
if (process.platform !== 'linux') return;
|
||||||
|
if (window !== overlayManager.getMainWindow()) return;
|
||||||
|
|
||||||
|
bindVisibleOverlayToTrackedX11Window(window);
|
||||||
|
|
||||||
|
const mediaSourceId = deps.getTrackedWindowMediaSourceId();
|
||||||
|
if (!mediaSourceId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.moveAbove(mediaSourceId);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logDebug(
|
||||||
|
'Failed to move visible overlay above tracked playback window:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindVisibleOverlayToTrackedX11Window(window: BrowserWindow): void {
|
||||||
|
const targetWindowId = deps.getTrackedWindowNativeId();
|
||||||
|
if (!targetWindowId) {
|
||||||
|
if (deps.getLinuxVisibleOverlayOwnerBindingKey() !== null) {
|
||||||
|
deps.clearVisibleOverlayX11OwnerBinding(window);
|
||||||
|
}
|
||||||
|
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayWindowId = deps.getNativeWindowHandleDecimal(window);
|
||||||
|
const bindingKey = `${overlayWindowId}:${targetWindowId}`;
|
||||||
|
if (deps.getLinuxVisibleOverlayOwnerBindingKey() === bindingKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.setLinuxVisibleOverlayOwnerBindingKey(bindingKey);
|
||||||
|
|
||||||
|
deps.enqueueVisibleOverlayX11OwnerBindingOperation(
|
||||||
|
window,
|
||||||
|
[
|
||||||
|
'-id',
|
||||||
|
overlayWindowId,
|
||||||
|
'-f',
|
||||||
|
'WM_TRANSIENT_FOR',
|
||||||
|
'32x',
|
||||||
|
'-set',
|
||||||
|
'WM_TRANSIENT_FOR',
|
||||||
|
targetWindowId,
|
||||||
|
],
|
||||||
|
(error) => {
|
||||||
|
if (deps.getLinuxVisibleOverlayOwnerBindingKey() === bindingKey) {
|
||||||
|
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
|
||||||
|
}
|
||||||
|
deps.logDebug(
|
||||||
|
'Failed to bind visible overlay as transient for tracked X11 playback window:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEnforceOverlayLayerOrderMainDepsHandler =
|
||||||
|
createBuildEnforceOverlayLayerOrderMainDepsHandler({
|
||||||
|
enforceOverlayLayerOrderCore: (params) =>
|
||||||
|
enforceOverlayLayerOrderCore({
|
||||||
|
visibleOverlayVisible: params.visibleOverlayVisible,
|
||||||
|
mainWindow: params.mainWindow as BrowserWindow | null,
|
||||||
|
ensureOverlayWindowLevel: (window) =>
|
||||||
|
params.ensureOverlayWindowLevel(window as BrowserWindow),
|
||||||
|
}),
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
|
||||||
|
});
|
||||||
|
const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler();
|
||||||
|
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
|
||||||
|
enforceOverlayLayerOrderMainDeps,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry,
|
||||||
|
resetLastOverlayWindowGeometry: () => {
|
||||||
|
lastOverlayWindowGeometry = null;
|
||||||
|
},
|
||||||
|
getOverlayGeometryFallback,
|
||||||
|
getCurrentOverlayGeometry,
|
||||||
|
getCurrentTrackedOverlayGeometry,
|
||||||
|
geometryMatches,
|
||||||
|
applyOverlayRegions,
|
||||||
|
shouldExitLinuxFullscreenOverrideForGeometry,
|
||||||
|
maybeExitLinuxFullscreenOverrideForTrackedGeometry,
|
||||||
|
hasHyprlandOverlayWindowPlacementMismatch,
|
||||||
|
moveVisibleOverlayAboveTrackedPlaybackWindow,
|
||||||
|
bindVisibleOverlayToTrackedX11Window,
|
||||||
|
syncPrimaryOverlayWindowLayer,
|
||||||
|
updateVisibleOverlayBounds,
|
||||||
|
ensureOverlayWindowLevel,
|
||||||
|
enforceOverlayLayerOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverlayGeometryRuntime = ReturnType<typeof createOverlayGeometryRuntime>;
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type {
|
||||||
|
NotificationType,
|
||||||
|
OverlayNotificationEventPayload,
|
||||||
|
OverlayNotificationPayload,
|
||||||
|
ResolvedConfig,
|
||||||
|
} from '../../types';
|
||||||
|
import type { AnkiIntegration } from '../../anki-integration';
|
||||||
|
import type { RuntimeOptionsManager } from '../../runtime-options';
|
||||||
|
import { AnkiConnectClient } from '../../anki-connect';
|
||||||
|
import { DEFAULT_CONFIG } from '../../config';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import { showDesktopNotification } from '../../core/utils';
|
||||||
|
import {
|
||||||
|
isOverlayWindowContentReady,
|
||||||
|
sendMpvCommandRuntime,
|
||||||
|
type MpvIpcClient,
|
||||||
|
} from '../../core/services';
|
||||||
|
import { createOverlayLoadingOsdController } from './overlay-loading-osd';
|
||||||
|
import { createMaybeStartOverlayLoadingOsdHandler } from './overlay-loading-osd-start';
|
||||||
|
import { withConfiguredOverlayNotificationPosition } from './overlay-notification-position';
|
||||||
|
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||||
|
import {
|
||||||
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
notifyConfiguredStatus,
|
||||||
|
type ConfiguredStatusNotificationOptions,
|
||||||
|
} from './configured-status-notification';
|
||||||
|
import { resolveOverlayReadinessNotificationType } from './notification-routing';
|
||||||
|
|
||||||
|
export interface OverlayNotificationsRuntimeDeps {
|
||||||
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
|
getMainOverlayWindow: () => BrowserWindow | null;
|
||||||
|
getVisibleOverlayVisible: () => boolean;
|
||||||
|
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||||
|
showMpvOsd: (message: string) => void;
|
||||||
|
getMpvClient: () => MpvIpcClient | null;
|
||||||
|
getAnkiIntegration: () => AnkiIntegration | null;
|
||||||
|
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRuntimeDeps): {
|
||||||
|
isVisibleOverlayContentReady: () => boolean;
|
||||||
|
getConfiguredStatusNotificationType: () => NotificationType;
|
||||||
|
flushQueuedOverlayNotifications: () => void;
|
||||||
|
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
||||||
|
dismissOverlayNotification: (id: string) => void;
|
||||||
|
openAnkiCardFromNotification: (noteId: number) => Promise<void>;
|
||||||
|
toggleNotificationHistoryPanel: () => void;
|
||||||
|
showConfiguredStatusNotification: (
|
||||||
|
message: string,
|
||||||
|
options?: ConfiguredStatusNotificationOptions,
|
||||||
|
) => void;
|
||||||
|
showConfiguredPlaybackFeedback: (
|
||||||
|
message: string,
|
||||||
|
options?: ConfiguredStatusNotificationOptions,
|
||||||
|
) => void;
|
||||||
|
showSubsyncStatusNotification: (message: string) => void;
|
||||||
|
showYoutubeFlowStatusNotification: (message: string) => void;
|
||||||
|
showOverlayLoadingStatusNotification: () => void;
|
||||||
|
dismissOverlayLoadingStatusNotification: () => void;
|
||||||
|
maybeStartOverlayLoadingOsd: (mediaPath?: string | null) => void;
|
||||||
|
} {
|
||||||
|
function isVisibleOverlayContentReady(): boolean {
|
||||||
|
const overlayWindow = deps.getMainOverlayWindow();
|
||||||
|
return Boolean(
|
||||||
|
deps.getVisibleOverlayVisible() &&
|
||||||
|
overlayWindow &&
|
||||||
|
isOverlayWindowReadyForNotification(overlayWindow),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredStatusNotificationType(): NotificationType {
|
||||||
|
const configuredType = deps.getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||||
|
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
|
||||||
|
if (window.isDestroyed() || !isOverlayWindowContentReady(window)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (window.webContents.isLoading()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentURL = window.webContents.getURL();
|
||||||
|
return currentURL !== '' && currentURL !== 'about:blank';
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayNotificationDelivery = createOverlayNotificationDelivery({
|
||||||
|
hasReadyOverlayWindow: () => isVisibleOverlayContentReady(),
|
||||||
|
send: (payload) => {
|
||||||
|
deps.broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
|
||||||
|
},
|
||||||
|
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
|
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||||
|
});
|
||||||
|
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
function flushQueuedOverlayNotifications(): void {
|
||||||
|
overlayNotificationDelivery.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
|
||||||
|
overlayNotificationDelivery.send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOverlayNotification(payload: OverlayNotificationPayload): void {
|
||||||
|
sendOverlayNotificationEvent(
|
||||||
|
withConfiguredOverlayNotificationPosition(payload, deps.getResolvedConfig()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissOverlayNotification(id: string): void {
|
||||||
|
sendOverlayNotificationEvent({ id, dismiss: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAnkiCardFromNotification(noteId: number): Promise<void> {
|
||||||
|
const activeIntegrationOpen = deps.getAnkiIntegration()?.openNoteInAnki(noteId);
|
||||||
|
if (activeIntegrationOpen) {
|
||||||
|
await activeIntegrationOpen;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedConfig = deps.getResolvedConfig();
|
||||||
|
const effectiveAnkiConfig =
|
||||||
|
deps.getRuntimeOptionsManager()?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
|
||||||
|
resolvedConfig.ankiConnect;
|
||||||
|
const fallbackClient = new AnkiConnectClient(
|
||||||
|
effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url,
|
||||||
|
);
|
||||||
|
await fallbackClient.openNoteInBrowser(noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNotificationHistoryPanel(): void {
|
||||||
|
deps.broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfiguredStatusNotification(
|
||||||
|
message: string,
|
||||||
|
options: ConfiguredStatusNotificationOptions = {},
|
||||||
|
): void {
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||||
|
isOverlayReady: () => isVisibleOverlayContentReady(),
|
||||||
|
showOsd: (text) => deps.showMpvOsd(text),
|
||||||
|
showOverlayNotification,
|
||||||
|
showDesktopNotification: (title, notificationOptions) =>
|
||||||
|
showDesktopNotification(title, notificationOptions),
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfiguredPlaybackFeedback(
|
||||||
|
message: string,
|
||||||
|
options: ConfiguredStatusNotificationOptions = {},
|
||||||
|
): void {
|
||||||
|
showConfiguredStatusNotification(message, {
|
||||||
|
...getPlaybackFeedbackNotificationOptions(message),
|
||||||
|
...options,
|
||||||
|
delivery: 'feedback',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSubsyncStatusNotification(message: string): void {
|
||||||
|
const syncing = message.startsWith('Subsync: syncing');
|
||||||
|
const failed = message.toLowerCase().includes('failed');
|
||||||
|
showConfiguredStatusNotification(message, {
|
||||||
|
id: 'subsync-status',
|
||||||
|
title: 'Subsync',
|
||||||
|
variant: failed ? 'error' : syncing ? 'progress' : 'info',
|
||||||
|
persistent: syncing,
|
||||||
|
desktop: !syncing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showYoutubeFlowStatusNotification(message: string): void {
|
||||||
|
const progress =
|
||||||
|
message.startsWith('Downloading subtitles') ||
|
||||||
|
message.startsWith('Loading subtitles') ||
|
||||||
|
message.startsWith('Getting subtitles') ||
|
||||||
|
message === 'Opening YouTube video';
|
||||||
|
showConfiguredStatusNotification(message, {
|
||||||
|
id: 'youtube-subtitles-status',
|
||||||
|
title: 'YouTube subtitles',
|
||||||
|
variant: progress ? 'progress' : 'info',
|
||||||
|
persistent: progress,
|
||||||
|
desktop: !progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
|
||||||
|
if (!overlayLoadingOsdController) {
|
||||||
|
overlayLoadingOsdController = createOverlayLoadingOsdController({
|
||||||
|
showOsd: (message) => {
|
||||||
|
deps.showMpvOsd(message);
|
||||||
|
},
|
||||||
|
clearOsd: () => {
|
||||||
|
sendMpvCommandRuntime(deps.getMpvClient(), ['show-text', '', '1']);
|
||||||
|
},
|
||||||
|
setInterval: (callback, delayMs) => {
|
||||||
|
const timer = setInterval(callback, delayMs);
|
||||||
|
timer.unref?.();
|
||||||
|
return timer;
|
||||||
|
},
|
||||||
|
clearInterval: (timer) => {
|
||||||
|
clearInterval(timer as ReturnType<typeof setInterval>);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return overlayLoadingOsdController;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOverlayLoadingStatusNotification(): void {
|
||||||
|
getOverlayLoadingOsdController().start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissOverlayLoadingStatusNotification(): void {
|
||||||
|
getOverlayLoadingOsdController().stop();
|
||||||
|
sendMpvCommandRuntime(deps.getMpvClient(), [
|
||||||
|
'script-message',
|
||||||
|
'subminer-overlay-loading-ready',
|
||||||
|
]);
|
||||||
|
dismissOverlayNotification('overlay-loading-status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({
|
||||||
|
getVisibleOverlayRequested: () => deps.getVisibleOverlayVisible(),
|
||||||
|
isOverlayContentReady: () => isVisibleOverlayContentReady(),
|
||||||
|
startOverlayLoadingOsd: () => {
|
||||||
|
showOverlayLoadingStatusNotification();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isVisibleOverlayContentReady,
|
||||||
|
getConfiguredStatusNotificationType,
|
||||||
|
flushQueuedOverlayNotifications,
|
||||||
|
showOverlayNotification,
|
||||||
|
dismissOverlayNotification,
|
||||||
|
openAnkiCardFromNotification,
|
||||||
|
toggleNotificationHistoryPanel,
|
||||||
|
showConfiguredStatusNotification,
|
||||||
|
showConfiguredPlaybackFeedback,
|
||||||
|
showSubsyncStatusNotification,
|
||||||
|
showYoutubeFlowStatusNotification,
|
||||||
|
showOverlayLoadingStatusNotification,
|
||||||
|
dismissOverlayLoadingStatusNotification,
|
||||||
|
maybeStartOverlayLoadingOsd,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { CompiledSessionBinding, ResolvedConfig } from '../../types';
|
||||||
|
import { createSessionBindingsRuntime } from './session-bindings-runtime';
|
||||||
|
|
||||||
|
test('persistSessionBindings logs and does not publish bindings when artifact write fails', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-bindings-runtime-'));
|
||||||
|
const configDir = path.join(root, 'config-file');
|
||||||
|
fs.writeFileSync(configDir, 'not a directory');
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runtime = createSessionBindingsRuntime({
|
||||||
|
configDir,
|
||||||
|
getKeybindings: () => [],
|
||||||
|
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
|
||||||
|
getResolvedConfig: () =>
|
||||||
|
({
|
||||||
|
stats: { toggleKey: 's', markWatchedKey: 'w' },
|
||||||
|
}) as ResolvedConfig,
|
||||||
|
getMpvClient: () => null,
|
||||||
|
setSessionBindings: () => calls.push('setSessionBindings'),
|
||||||
|
setSessionBindingsInitialized: () => calls.push('setSessionBindingsInitialized'),
|
||||||
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() => runtime.persistSessionBindings([] as CompiledSessionBinding[]),
|
||||||
|
/ENOTDIR|EEXIST/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls, ['warn:[session-bindings] Failed to write session bindings artifact']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persistSessionBindings keeps saved bindings when mpv reload notification fails', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-bindings-runtime-'));
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runtime = createSessionBindingsRuntime({
|
||||||
|
configDir: root,
|
||||||
|
getKeybindings: () => [],
|
||||||
|
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
|
||||||
|
getResolvedConfig: () =>
|
||||||
|
({
|
||||||
|
stats: { toggleKey: 's', markWatchedKey: 'w' },
|
||||||
|
}) as ResolvedConfig,
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
connected: true,
|
||||||
|
send: () => {
|
||||||
|
throw new Error('mpv unavailable');
|
||||||
|
},
|
||||||
|
}) as never,
|
||||||
|
setSessionBindings: () => calls.push('setSessionBindings'),
|
||||||
|
setSessionBindingsInitialized: () => calls.push('setSessionBindingsInitialized'),
|
||||||
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.doesNotThrow(() => runtime.persistSessionBindings([] as CompiledSessionBinding[]));
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'setSessionBindings',
|
||||||
|
'setSessionBindingsInitialized',
|
||||||
|
'warn:[session-bindings] Failed to notify mpv to reload session bindings',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { sendMpvCommandRuntime, type MpvRuntimeClientLike } from '../../core/services';
|
||||||
|
import {
|
||||||
|
buildPluginSessionBindingsArtifact,
|
||||||
|
compileSessionBindings,
|
||||||
|
} from '../../core/services/session-bindings';
|
||||||
|
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||||
|
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
||||||
|
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
|
||||||
|
|
||||||
|
export interface SessionBindingsRuntimeDeps {
|
||||||
|
configDir: string;
|
||||||
|
getKeybindings: () => Keybinding[];
|
||||||
|
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||||
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
|
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||||
|
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
||||||
|
setSessionBindingsInitialized: (initialized: boolean) => void;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
||||||
|
persistSessionBindings: (
|
||||||
|
bindings: CompiledSessionBinding[],
|
||||||
|
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
||||||
|
) => void;
|
||||||
|
refreshCurrentSessionBindings: () => void;
|
||||||
|
} {
|
||||||
|
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
||||||
|
if (process.platform === 'darwin') return 'darwin';
|
||||||
|
if (process.platform === 'win32') return 'win32';
|
||||||
|
return 'linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileCurrentSessionBindings(): {
|
||||||
|
bindings: CompiledSessionBinding[];
|
||||||
|
warnings: ReturnType<typeof compileSessionBindings>['warnings'];
|
||||||
|
} {
|
||||||
|
return compileSessionBindings({
|
||||||
|
keybindings: deps.getKeybindings(),
|
||||||
|
shortcuts: deps.getConfiguredShortcuts(),
|
||||||
|
statsToggleKey: deps.getResolvedConfig().stats.toggleKey,
|
||||||
|
statsMarkWatchedKey: deps.getResolvedConfig().stats.markWatchedKey,
|
||||||
|
platform: resolveSessionBindingPlatform(),
|
||||||
|
rawConfig: deps.getResolvedConfig(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSessionBindings(
|
||||||
|
bindings: CompiledSessionBinding[],
|
||||||
|
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||||
|
): void {
|
||||||
|
const artifact = buildPluginSessionBindingsArtifact({
|
||||||
|
bindings,
|
||||||
|
warnings,
|
||||||
|
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
writeSessionBindingsArtifact(deps.configDir, artifact);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
deps.setSessionBindings(bindings);
|
||||||
|
deps.setSessionBindingsInitialized(true);
|
||||||
|
const mpvClient = deps.getMpvClient();
|
||||||
|
if (mpvClient?.connected) {
|
||||||
|
try {
|
||||||
|
sendMpvCommandRuntime(mpvClient, ['script-message', 'subminer-reload-session-bindings']);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCurrentSessionBindings(): void {
|
||||||
|
const compiled = compileCurrentSessionBindings();
|
||||||
|
for (const warning of compiled.warnings) {
|
||||||
|
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||||
|
}
|
||||||
|
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { persistSessionBindings, refreshCurrentSessionBindings };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
export type BackgroundStatsServerState = {
|
export type BackgroundStatsServerState = {
|
||||||
@@ -65,6 +66,43 @@ export function isBackgroundStatsServerProcessAlive(pid: number): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readProcessStartedAtMs(pid: number): number | null {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const output = execFileSync(
|
||||||
|
'powershell.exe',
|
||||||
|
[
|
||||||
|
'-NoProfile',
|
||||||
|
'-Command',
|
||||||
|
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate.ToUniversalTime().ToString("o")`,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8', timeout: 1000 },
|
||||||
|
).trim();
|
||||||
|
const parsed = Date.parse(output);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 1000,
|
||||||
|
}).trim();
|
||||||
|
const parsed = Date.parse(output);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyBackgroundStatsServerIdentity(pid: number, startedAtMs: number): boolean {
|
||||||
|
const processStartedAtMs = readProcessStartedAtMs(pid);
|
||||||
|
if (processStartedAtMs === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const earliestAllowedStateWriteMs = processStartedAtMs;
|
||||||
|
const latestAllowedStateWriteMs = processStartedAtMs + 60_000;
|
||||||
|
return startedAtMs >= earliestAllowedStateWriteMs && startedAtMs <= latestAllowedStateWriteMs;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveBackgroundStatsServerUrl(
|
export function resolveBackgroundStatsServerUrl(
|
||||||
state: Pick<BackgroundStatsServerState, 'port'>,
|
state: Pick<BackgroundStatsServerState, 'port'>,
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createStatsServerRuntime,
|
||||||
|
isSelfOwnedBackgroundStatsDaemonState,
|
||||||
|
shouldClearAppStateStatsServerOnStop,
|
||||||
|
} from './stats-server-runtime';
|
||||||
|
|
||||||
|
test('detects self-owned background stats daemon state', () => {
|
||||||
|
assert.equal(
|
||||||
|
isSelfOwnedBackgroundStatsDaemonState({ pid: process.pid, port: 6969, startedAtMs: 1 }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stats server app-state reference should be cleared after private server stop', () => {
|
||||||
|
assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runtime = createStatsServerRuntime({
|
||||||
|
userDataPath: '/tmp/subminer-stats-runtime-test',
|
||||||
|
statsDistPath: '/tmp/stats-dist',
|
||||||
|
getResolvedConfig: () => ({ stats: { serverPort: 5175 } }) as never,
|
||||||
|
getImmersionTracker: () => null,
|
||||||
|
setAppStateStatsServer: () => {},
|
||||||
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||||
|
getYomitanExt: () => null,
|
||||||
|
getYomitanSession: () => null,
|
||||||
|
getYomitanParserWindow: () => null,
|
||||||
|
setYomitanParserWindow: () => {},
|
||||||
|
getYomitanParserReadyPromise: () => null,
|
||||||
|
setYomitanParserReadyPromise: () => {},
|
||||||
|
getYomitanParserInitPromise: () => null,
|
||||||
|
setYomitanParserInitPromise: () => {},
|
||||||
|
getYomitanAnkiDeckName: async () => 'Mining',
|
||||||
|
getAnilistRateLimiter: () => ({}) as never,
|
||||||
|
resolveAnkiNoteId: (noteId) => noteId,
|
||||||
|
trackDuplicateNoteIdsForNote: () => {},
|
||||||
|
resolveSentenceSearchHeadwords: async () => [],
|
||||||
|
ensureImmersionTrackerStarted: () => {},
|
||||||
|
setStatsStartupInProgress: () => {},
|
||||||
|
readBackgroundStatsServerState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }),
|
||||||
|
removeBackgroundStatsServerState: () => {
|
||||||
|
calls.push('removeBackgroundStatsServerState');
|
||||||
|
},
|
||||||
|
isBackgroundStatsServerProcessAlive: () => true,
|
||||||
|
verifyBackgroundStatsServerIdentity: () => false,
|
||||||
|
killProcess: () => {
|
||||||
|
calls.push('killProcess');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.stopBackgroundStatsServer();
|
||||||
|
|
||||||
|
assert.deepEqual(result, { ok: true, stale: true });
|
||||||
|
assert.deepEqual(calls, ['removeBackgroundStatsServerState']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import {
|
||||||
|
addYomitanNoteViaSearch,
|
||||||
|
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||||
|
} from '../../core/services';
|
||||||
|
import { startStatsServer } from '../../core/services/stats-server';
|
||||||
|
import { createLogger } from '../../logger';
|
||||||
|
import type { ResolvedConfig } from '../../types/config';
|
||||||
|
import type { AppState } from '../state';
|
||||||
|
import {
|
||||||
|
isBackgroundStatsServerProcessAlive as defaultIsBackgroundStatsServerProcessAlive,
|
||||||
|
readBackgroundStatsServerState as defaultReadBackgroundStatsServerState,
|
||||||
|
removeBackgroundStatsServerState as defaultRemoveBackgroundStatsServerState,
|
||||||
|
resolveBackgroundStatsServerUrl,
|
||||||
|
verifyBackgroundStatsServerIdentity as defaultVerifyBackgroundStatsServerIdentity,
|
||||||
|
writeBackgroundStatsServerState,
|
||||||
|
} from './stats-daemon';
|
||||||
|
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
|
||||||
|
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
|
||||||
|
|
||||||
|
export function isSelfOwnedBackgroundStatsDaemonState(state: {
|
||||||
|
pid: number;
|
||||||
|
port?: number;
|
||||||
|
startedAtMs?: number;
|
||||||
|
}): boolean {
|
||||||
|
return state.pid === process.pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldClearAppStateStatsServerOnStop(options: {
|
||||||
|
hadStatsServer: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return options.hadStatsServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsServerRuntimeDeps {
|
||||||
|
userDataPath: string;
|
||||||
|
statsDistPath: string;
|
||||||
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
|
getImmersionTracker: () => AppState['immersionTracker'];
|
||||||
|
setAppStateStatsServer: (server: AppState['statsServer']) => void;
|
||||||
|
getMpvSocketPath: () => AppState['mpvSocketPath'];
|
||||||
|
getYomitanExt: () => AppState['yomitanExt'];
|
||||||
|
getYomitanSession: () => AppState['yomitanSession'];
|
||||||
|
getYomitanParserWindow: () => AppState['yomitanParserWindow'];
|
||||||
|
setYomitanParserWindow: (w: BrowserWindow | null) => void;
|
||||||
|
getYomitanParserReadyPromise: () => AppState['yomitanParserReadyPromise'];
|
||||||
|
setYomitanParserReadyPromise: (p: Promise<void> | null) => void;
|
||||||
|
getYomitanParserInitPromise: () => AppState['yomitanParserInitPromise'];
|
||||||
|
setYomitanParserInitPromise: (p: Promise<boolean> | null) => void;
|
||||||
|
getYomitanAnkiDeckName: () => Promise<string>;
|
||||||
|
getAnilistRateLimiter: () => NonNullable<
|
||||||
|
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
|
||||||
|
>;
|
||||||
|
resolveAnkiNoteId: (noteId: number) => number;
|
||||||
|
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
|
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
|
||||||
|
ensureImmersionTrackerStarted: () => void;
|
||||||
|
setStatsStartupInProgress: (inProgress: boolean) => void;
|
||||||
|
readBackgroundStatsServerState?: typeof defaultReadBackgroundStatsServerState;
|
||||||
|
removeBackgroundStatsServerState?: typeof defaultRemoveBackgroundStatsServerState;
|
||||||
|
isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive;
|
||||||
|
verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity;
|
||||||
|
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||||
|
stopStatsServer: () => void;
|
||||||
|
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
|
||||||
|
ensureBackgroundStatsServerStarted: () => {
|
||||||
|
url: string;
|
||||||
|
runningInCurrentProcess: boolean;
|
||||||
|
};
|
||||||
|
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
||||||
|
} {
|
||||||
|
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
||||||
|
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
|
||||||
|
const readDaemonState =
|
||||||
|
deps.readBackgroundStatsServerState ??
|
||||||
|
((statePath: string) => defaultReadBackgroundStatsServerState(statePath));
|
||||||
|
const removeDaemonState =
|
||||||
|
deps.removeBackgroundStatsServerState ??
|
||||||
|
((statePath: string) => defaultRemoveBackgroundStatsServerState(statePath));
|
||||||
|
const isDaemonAlive =
|
||||||
|
deps.isBackgroundStatsServerProcessAlive ??
|
||||||
|
((pid: number) => defaultIsBackgroundStatsServerProcessAlive(pid));
|
||||||
|
const verifyDaemonIdentity =
|
||||||
|
deps.verifyBackgroundStatsServerIdentity ??
|
||||||
|
((pid: number, startedAtMs: number) =>
|
||||||
|
defaultVerifyBackgroundStatsServerIdentity(pid, startedAtMs));
|
||||||
|
const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal));
|
||||||
|
|
||||||
|
function readLiveBackgroundStatsDaemonState(): {
|
||||||
|
pid: number;
|
||||||
|
port: number;
|
||||||
|
startedAtMs: number;
|
||||||
|
} | null {
|
||||||
|
const state = readDaemonState(statsDaemonStatePath);
|
||||||
|
if (!state) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (state.pid === process.pid && !statsServer) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isDaemonAlive(state.pid)) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOwnedBackgroundStatsDaemonState(): void {
|
||||||
|
const state = readDaemonState(statsDaemonStatePath);
|
||||||
|
if (state?.pid === process.pid) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStatsServer(): void {
|
||||||
|
if (!statsServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statsServer.close();
|
||||||
|
statsServer = null;
|
||||||
|
if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) {
|
||||||
|
deps.setAppStateStatsServer(null);
|
||||||
|
}
|
||||||
|
clearOwnedBackgroundStatsDaemonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLocalStatsServer = (): void => {
|
||||||
|
const tracker = deps.getImmersionTracker();
|
||||||
|
if (!tracker) {
|
||||||
|
throw new Error('Immersion tracker failed to initialize.');
|
||||||
|
}
|
||||||
|
if (!statsServer) {
|
||||||
|
const yomitanDeps = {
|
||||||
|
getYomitanExt: () => deps.getYomitanExt(),
|
||||||
|
getYomitanSession: () => deps.getYomitanSession(),
|
||||||
|
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
||||||
|
setYomitanParserWindow: (w: BrowserWindow | null) => {
|
||||||
|
deps.setYomitanParserWindow(w);
|
||||||
|
},
|
||||||
|
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
|
||||||
|
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
|
||||||
|
deps.setYomitanParserReadyPromise(p);
|
||||||
|
},
|
||||||
|
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
|
||||||
|
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
|
||||||
|
deps.setYomitanParserInitPromise(p);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const yomitanLogger = createLogger('main:yomitan-stats');
|
||||||
|
statsServer = startStatsServer({
|
||||||
|
port: deps.getResolvedConfig().stats.serverPort,
|
||||||
|
staticDir: deps.statsDistPath,
|
||||||
|
tracker,
|
||||||
|
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
|
||||||
|
mpvSocketPath: deps.getMpvSocketPath(),
|
||||||
|
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
|
||||||
|
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
|
||||||
|
getSecondarySubtitleLanguages: () =>
|
||||||
|
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||||
|
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
|
||||||
|
anilistRateLimiter: deps.getAnilistRateLimiter(),
|
||||||
|
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
||||||
|
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
||||||
|
addYomitanNote: async (word: string) => {
|
||||||
|
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||||
|
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||||
|
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||||
|
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||||
|
deck: ankiConnectConfig.deck,
|
||||||
|
});
|
||||||
|
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||||
|
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||||
|
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
|
||||||
|
}
|
||||||
|
return result.noteId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
deps.setAppStateStatsServer(statsServer);
|
||||||
|
}
|
||||||
|
deps.setAppStateStatsServer(statsServer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
|
||||||
|
currentPid: process.pid,
|
||||||
|
readBackgroundState: () => readDaemonState(statsDaemonStatePath),
|
||||||
|
removeBackgroundState: () => {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
},
|
||||||
|
isProcessAlive: (pid) => isDaemonAlive(pid),
|
||||||
|
hasLocalStatsServer: () => statsServer !== null,
|
||||||
|
startLocalStatsServer,
|
||||||
|
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensureBackgroundStatsServerStarted = (): {
|
||||||
|
url: string;
|
||||||
|
runningInCurrentProcess: boolean;
|
||||||
|
} => {
|
||||||
|
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
||||||
|
if (liveDaemon && liveDaemon.pid !== process.pid) {
|
||||||
|
return {
|
||||||
|
url: resolveBackgroundStatsServerUrl(liveDaemon),
|
||||||
|
runningInCurrentProcess: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.setStatsStartupInProgress(true);
|
||||||
|
try {
|
||||||
|
deps.ensureImmersionTrackerStarted();
|
||||||
|
} finally {
|
||||||
|
deps.setStatsStartupInProgress(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = deps.getResolvedConfig().stats.serverPort;
|
||||||
|
const result = ensureStatsServerStarted();
|
||||||
|
if (result.source === 'local') {
|
||||||
|
writeBackgroundStatsServerState(statsDaemonStatePath, {
|
||||||
|
pid: process.pid,
|
||||||
|
port,
|
||||||
|
startedAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
||||||
|
const state = readDaemonState(statsDaemonStatePath);
|
||||||
|
if (!state) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
if (!isDaemonAlive(state.pid)) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
if (!verifyDaemonIdentity(state.pid, state.startedAtMs)) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
killProcess(state.pid, 'SIGTERM');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
|
||||||
|
throw new Error(
|
||||||
|
`Insufficient permissions to stop background stats server (pid ${state.pid}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = Date.now() + 2_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!isDaemonAlive(state.pid)) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: false };
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Timed out stopping background stats server.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stopStatsServer,
|
||||||
|
ensureStatsServerStarted,
|
||||||
|
ensureBackgroundStatsServerStarted,
|
||||||
|
stopBackgroundStatsServer,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
|
||||||
|
|
||||||
|
test('runSupportAssetUpdatesForLauncherResult logs support-asset errors and preserves launcher result', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const launcherResult = { status: 'updated' } as const;
|
||||||
|
const result = await runSupportAssetUpdatesForLauncherResult({
|
||||||
|
launcherResult,
|
||||||
|
updateSupportAssets: async () => {
|
||||||
|
throw new Error('archive failed');
|
||||||
|
},
|
||||||
|
logWarn: (message, details) => {
|
||||||
|
warnings.push(`${message}:${details instanceof Error ? details.message : String(details)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, launcherResult);
|
||||||
|
assert.deepEqual(warnings, ['Support asset update failed after launcher update:archive failed']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runSupportAssetUpdatesForLauncherResult uses support asset description in skip warnings', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const launcherResult = { status: 'updated' } as const;
|
||||||
|
|
||||||
|
const result = await runSupportAssetUpdatesForLauncherResult({
|
||||||
|
launcherResult,
|
||||||
|
assetDescription: 'Support asset update',
|
||||||
|
updateSupportAssets: async () => [
|
||||||
|
{ status: 'protected', command: 'install-theme' },
|
||||||
|
{ status: 'hash-mismatch', message: 'checksum failed' },
|
||||||
|
],
|
||||||
|
logWarn: (message) => {
|
||||||
|
warnings.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, launcherResult);
|
||||||
|
assert.deepEqual(warnings, [
|
||||||
|
'Support asset update requires manual command: install-theme',
|
||||||
|
'Support asset update skipped: checksum failed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { app, dialog } from 'electron';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { UpdateChannel, UpdatesConfig } from '../../../types/config';
|
||||||
|
import type { OverlayNotificationPayload } from '../../../types/notification';
|
||||||
|
import { createElectronAppUpdater, isNativeUpdaterSupported } from './app-updater';
|
||||||
|
import { createCurlFetch, createGlobalFetch } from './fetch-adapter';
|
||||||
|
import { createCurlHttpExecutor } from './curl-http-executor';
|
||||||
|
import { createFetchHttpExecutor } from './fetch-http-executor';
|
||||||
|
import {
|
||||||
|
fetchLatestStableRelease,
|
||||||
|
fetchReleaseAssetBuffer,
|
||||||
|
fetchReleaseAssetText,
|
||||||
|
findReleaseAsset,
|
||||||
|
parseSha256Sums,
|
||||||
|
type GitHubRelease,
|
||||||
|
} from './release-assets';
|
||||||
|
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
|
||||||
|
import { updateLauncherFromRelease } from './launcher-updater';
|
||||||
|
import { notifyUpdateAvailable } from './update-notifications';
|
||||||
|
import { createUpdateDialogPresenter } from './update-dialogs';
|
||||||
|
import { createFileUpdateStateStore, createUpdateService } from './update-service';
|
||||||
|
import { updateSupportAssetsFromRelease } from './support-assets';
|
||||||
|
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
|
||||||
|
|
||||||
|
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
|
||||||
|
|
||||||
|
export interface UpdateServiceRuntimeDeps {
|
||||||
|
userDataPath: string;
|
||||||
|
getUpdatesConfig: () => Required<UpdatesConfig>;
|
||||||
|
logInfo: (message: string) => void;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
||||||
|
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||||
|
showMpvOsd: (message: string) => void;
|
||||||
|
withStatsWindowLayerSuspendedForNativeDialog: <T>(showDialog: () => Promise<T>) => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||||
|
getUpdateService: () => ReturnType<typeof createUpdateService>;
|
||||||
|
} {
|
||||||
|
const updateStateStore = createFileUpdateStateStore(
|
||||||
|
path.join(deps.userDataPath, 'update-state.json'),
|
||||||
|
);
|
||||||
|
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
||||||
|
const globalFetchForUpdater = createGlobalFetch();
|
||||||
|
const curlFetch = createCurlFetch();
|
||||||
|
|
||||||
|
function createNativeUpdaterHttpExecutor() {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return createFetchHttpExecutor();
|
||||||
|
}
|
||||||
|
return createCurlHttpExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFetchForUpdater() {
|
||||||
|
if (process.platform === 'win32') return globalFetchForUpdater;
|
||||||
|
return curlFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLauncherFromSelectedRelease(
|
||||||
|
launcherPath?: string,
|
||||||
|
channel: UpdateChannel = deps.getUpdatesConfig().channel,
|
||||||
|
release: GitHubRelease | null = null,
|
||||||
|
) {
|
||||||
|
const fetchForUpdater = getFetchForUpdater();
|
||||||
|
if (!release) {
|
||||||
|
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
|
||||||
|
}
|
||||||
|
const sumsAsset = findReleaseAsset(release, 'SHA256SUMS.txt');
|
||||||
|
if (!sumsAsset) {
|
||||||
|
return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' };
|
||||||
|
}
|
||||||
|
const sums = parseSha256Sums(
|
||||||
|
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
|
||||||
|
);
|
||||||
|
const launcherResult = await updateLauncherFromRelease({
|
||||||
|
release,
|
||||||
|
sha256Sums: sums,
|
||||||
|
launcherPath,
|
||||||
|
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
|
||||||
|
});
|
||||||
|
return runSupportAssetUpdatesForLauncherResult({
|
||||||
|
launcherResult,
|
||||||
|
assetDescription: 'Support asset update',
|
||||||
|
updateSupportAssets: () =>
|
||||||
|
updateSupportAssetsFromRelease({
|
||||||
|
release,
|
||||||
|
sha256Sums: sums,
|
||||||
|
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
|
||||||
|
}),
|
||||||
|
logWarn: (message, details) => deps.logWarn(message, details),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdateService() {
|
||||||
|
if (updateService) return updateService;
|
||||||
|
const appUpdater = createElectronAppUpdater({
|
||||||
|
currentVersion: app.getVersion(),
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
log: (message) => deps.logInfo(message),
|
||||||
|
getChannel: () => deps.getUpdatesConfig().channel,
|
||||||
|
configureHttpExecutor: createNativeUpdaterHttpExecutor,
|
||||||
|
disableDifferentialDownload: true,
|
||||||
|
isNativeUpdaterSupported: () =>
|
||||||
|
isNativeUpdaterSupported({
|
||||||
|
platform: process.platform,
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
execPath: process.execPath,
|
||||||
|
env: process.env,
|
||||||
|
log: (message) => deps.logWarn(message),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const updateDialogPresenter = createUpdateDialogPresenter({
|
||||||
|
platform: process.platform,
|
||||||
|
focusApp: async () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.focus({ steal: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await app.dock?.show();
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('Failed to show macOS dock before update dialog', error);
|
||||||
|
}
|
||||||
|
// app.focus({ steal: true }) alone does not reliably activate the process
|
||||||
|
// when SubMiner was reached via `subminer -u` (single-instance forwarding
|
||||||
|
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
|
||||||
|
// which is the only path that reliably brings the running app forward.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
execFile(
|
||||||
|
'/usr/bin/osascript',
|
||||||
|
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
|
||||||
|
{ timeout: 2000 },
|
||||||
|
(error) => {
|
||||||
|
if (error) {
|
||||||
|
deps.logWarn(
|
||||||
|
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
app.focus({ steal: true });
|
||||||
|
},
|
||||||
|
withStatsWindowLayerSuspended: (showDialog) =>
|
||||||
|
deps.withStatsWindowLayerSuspendedForNativeDialog(showDialog),
|
||||||
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
|
});
|
||||||
|
updateService = createUpdateService({
|
||||||
|
getConfig: () => deps.getUpdatesConfig(),
|
||||||
|
getCurrentVersion: () => app.getVersion(),
|
||||||
|
now: () => Date.now(),
|
||||||
|
readState: () => updateStateStore.readState(),
|
||||||
|
writeState: (state) => updateStateStore.writeState(state),
|
||||||
|
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||||
|
shouldFetchReleaseMetadata: ({ request, appUpdate }) =>
|
||||||
|
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request),
|
||||||
|
fetchLatestStableRelease: (channel) =>
|
||||||
|
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
|
||||||
|
updateLauncher: (launcherPath, channel, release) =>
|
||||||
|
updateLauncherFromSelectedRelease(launcherPath, channel, release),
|
||||||
|
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
|
||||||
|
showUpdateAvailableDialog: (version) =>
|
||||||
|
updateDialogPresenter.showUpdateAvailableDialog(version),
|
||||||
|
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
|
||||||
|
showManualUpdateRequiredDialog: (version) =>
|
||||||
|
updateDialogPresenter.showManualUpdateRequiredDialog(version),
|
||||||
|
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
||||||
|
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
|
||||||
|
quitAndInstall: () => appUpdater.quitAndInstall(),
|
||||||
|
notifyUpdateAvailable: (version) =>
|
||||||
|
notifyUpdateAvailable(
|
||||||
|
{ notificationType: deps.getUpdatesConfig().notificationType, version },
|
||||||
|
{
|
||||||
|
showSystemNotification: (title, body) => deps.showDesktopNotification(title, { body }),
|
||||||
|
showOverlayNotification: (payload) => deps.showOverlayNotification(payload),
|
||||||
|
showOsdNotification: (message) => {
|
||||||
|
deps.showMpvOsd(message);
|
||||||
|
},
|
||||||
|
log: (message) => deps.logWarn(message),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
log: (message) => deps.logWarn(message),
|
||||||
|
});
|
||||||
|
return updateService;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getUpdateService };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export async function runSupportAssetUpdatesForLauncherResult<
|
||||||
|
TLauncherResult,
|
||||||
|
TSupportResult extends { status: string; command?: string; message?: string },
|
||||||
|
>(options: {
|
||||||
|
launcherResult: TLauncherResult;
|
||||||
|
assetDescription?: string;
|
||||||
|
updateSupportAssets: () => Promise<TSupportResult[]>;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
}): Promise<TLauncherResult> {
|
||||||
|
const assetDescription = options.assetDescription ?? 'Support asset update';
|
||||||
|
try {
|
||||||
|
const supportResults = await options.updateSupportAssets();
|
||||||
|
for (const result of supportResults) {
|
||||||
|
if (result.status === 'protected' && result.command) {
|
||||||
|
options.logWarn(`${assetDescription} requires manual command: ${result.command}`);
|
||||||
|
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
|
||||||
|
options.logWarn(`${assetDescription} skipped: ${result.message ?? result.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
options.logWarn('Support asset update failed after launcher update', error);
|
||||||
|
}
|
||||||
|
return options.launcherResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
import { type BrowserWindow, screen } from 'electron';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
|
||||||
|
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
|
||||||
|
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
||||||
|
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
|
||||||
|
import type { BaseWindowTracker } from '../../window-trackers';
|
||||||
|
import {
|
||||||
|
bindWindowsOverlayAboveMpv,
|
||||||
|
clearWindowsOverlayOwner,
|
||||||
|
findWindowsMpvTargetWindowHandle,
|
||||||
|
getWindowsForegroundProcessName,
|
||||||
|
setWindowsOverlayOwner,
|
||||||
|
} from '../../window-trackers/windows-helper';
|
||||||
|
import {
|
||||||
|
applyLinuxOverlayInputShape,
|
||||||
|
applyLinuxOverlayPointerInteractionMousePassthrough,
|
||||||
|
ensureLinuxOverlayPointerInteractionLoop,
|
||||||
|
type ForegroundSuppressionGraceState,
|
||||||
|
mapOverlayMeasurementForPointerInteraction,
|
||||||
|
resolveForegroundSuppressionWithGrace,
|
||||||
|
shouldPrimeLinuxOverlayInteractionFromMeasurement,
|
||||||
|
tickLinuxOverlayPointerInteraction,
|
||||||
|
} from './linux-overlay-pointer-interaction';
|
||||||
|
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
|
||||||
|
import {
|
||||||
|
ensureLinuxOverlayZOrderKeepAliveLoop,
|
||||||
|
shouldRunLinuxOverlayZOrderKeepAlive,
|
||||||
|
tickLinuxOverlayZOrderKeepAlive,
|
||||||
|
} from './linux-overlay-zorder-keepalive';
|
||||||
|
import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
|
||||||
|
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
|
||||||
|
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
|
||||||
|
import { hasLiveSeparateWindow } from './settings-window-z-order';
|
||||||
|
|
||||||
|
export interface VisibleOverlayInteractionRuntimeDeps {
|
||||||
|
overlayManager: {
|
||||||
|
getMainWindow: () => BrowserWindow | null;
|
||||||
|
getVisibleOverlayVisible: () => boolean;
|
||||||
|
};
|
||||||
|
overlayContentMeasurementStore: {
|
||||||
|
clear: (layer: 'visible') => void;
|
||||||
|
getLatestByLayer: (layer: 'visible') => OverlayContentMeasurement | null;
|
||||||
|
};
|
||||||
|
logger: {
|
||||||
|
info: (message: string, ...args: unknown[]) => void;
|
||||||
|
warn: (message: string, ...args: unknown[]) => void;
|
||||||
|
debug: (message: string, ...args: unknown[]) => void;
|
||||||
|
};
|
||||||
|
updateVisibleOverlayVisibility: () => void;
|
||||||
|
getModalInputExclusive: () => boolean;
|
||||||
|
getStatsOverlayVisible: () => boolean;
|
||||||
|
setStatsOverlayVisible: (visible: boolean) => void;
|
||||||
|
getWindowTracker: () => BaseWindowTracker | null;
|
||||||
|
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||||
|
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||||
|
getMpvSocketPath: () => string;
|
||||||
|
getBackendOverride: () => string | null;
|
||||||
|
getInitialArgs: () => CliArgs | null;
|
||||||
|
getOverlayRuntimeInitialized: () => boolean;
|
||||||
|
getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode;
|
||||||
|
setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void;
|
||||||
|
bindVisibleOverlayToTrackedX11Window: (window: BrowserWindow) => void;
|
||||||
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
|
refreshCurrentSubtitle: () => void;
|
||||||
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
|
syncOverlayShortcuts: () => void;
|
||||||
|
resetLastOverlayWindowGeometry: () => void;
|
||||||
|
enforceOverlayLayerOrder: () => void;
|
||||||
|
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInteractionRuntimeDeps) {
|
||||||
|
const { overlayManager, overlayContentMeasurementStore, logger } = deps;
|
||||||
|
|
||||||
|
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
||||||
|
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||||
|
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||||
|
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||||
|
const LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 1_500;
|
||||||
|
// Ignore transient "neither mpv nor overlay is the active window" blips before suppressing
|
||||||
|
// subtitle pointer interaction. Right after playback starts the overlay can briefly become the
|
||||||
|
// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s).
|
||||||
|
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500;
|
||||||
|
const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500;
|
||||||
|
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
|
||||||
|
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
|
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||||
|
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||||
|
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||||
|
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
|
||||||
|
const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = {
|
||||||
|
lossSinceMs: null,
|
||||||
|
};
|
||||||
|
let visibleOverlayInteractionActive = false;
|
||||||
|
let linuxOverlayInputShapeActive = false;
|
||||||
|
let linuxVisibleOverlayStartupInputPrimed = false;
|
||||||
|
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||||
|
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
|
||||||
|
// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
|
||||||
|
// moves off measured subtitle/sidebar rects onto the popup.
|
||||||
|
let linuxOverlayInteractiveHint = false;
|
||||||
|
let macOSVisibleOverlayForegroundProbeActive = false;
|
||||||
|
let macOSVisibleOverlayForegroundProbeToken = 0;
|
||||||
|
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const linuxVisibleOverlayOwnerBindingQueues = new WeakMap<BrowserWindow, Promise<void>>();
|
||||||
|
|
||||||
|
const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({
|
||||||
|
setStatsOverlayVisibleState: (visible) => {
|
||||||
|
deps.setStatsOverlayVisible(visible);
|
||||||
|
},
|
||||||
|
resetVisibleOverlayInteraction: () => {
|
||||||
|
visibleOverlayInteractionActive = false;
|
||||||
|
},
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetVisibleOverlayInputState(): void {
|
||||||
|
visibleOverlayInteractionActive = false;
|
||||||
|
linuxOverlayInputShapeActive = false;
|
||||||
|
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||||
|
linuxOverlayInteractiveHint = false;
|
||||||
|
overlayContentMeasurementStore.clear('visible');
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (process.platform === 'linux' && mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
restoreLinuxOverlayWindowShape(mainWindow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreVisibleOverlayWindowShapeForShow(): void {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreLinuxOverlayWindowShape(overlayManager.getMainWindow());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVisibleOverlayBlurRefreshTimeouts(): void {
|
||||||
|
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
visibleOverlayBlurRefreshTimeouts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||||
|
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
windowsVisibleOverlayZOrderRetryTimeouts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
|
||||||
|
if (token !== macOSVisibleOverlayForegroundProbeToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||||
|
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||||
|
macOSVisibleOverlayForegroundProbeTimeout = null;
|
||||||
|
}
|
||||||
|
if (!macOSVisibleOverlayForegroundProbeActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
macOSVisibleOverlayForegroundProbeActive = false;
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMacOSVisibleOverlayForegroundProbe(): void {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tracker = deps.getWindowTracker();
|
||||||
|
if (!tracker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
macOSVisibleOverlayForegroundProbeActive = true;
|
||||||
|
const token = ++macOSVisibleOverlayForegroundProbeToken;
|
||||||
|
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||||
|
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||||
|
}
|
||||||
|
macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => {
|
||||||
|
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||||
|
}, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS);
|
||||||
|
|
||||||
|
void tracker
|
||||||
|
.refreshNow()
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn('Failed to refresh macOS frontmost app after overlay blur', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNativeWindowHandleDecimal(window: BrowserWindow): string {
|
||||||
|
const handle = window.getNativeWindowHandle();
|
||||||
|
return handle.length >= 8
|
||||||
|
? handle.readBigUInt64LE(0).toString()
|
||||||
|
: BigInt(handle.readUInt32LE(0)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
|
||||||
|
return getNativeWindowHandleDecimal(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
|
||||||
|
const handle = window.getNativeWindowHandle();
|
||||||
|
return handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueVisibleOverlayX11OwnerBindingOperation(
|
||||||
|
window: BrowserWindow,
|
||||||
|
args: string[],
|
||||||
|
onError?: (error: Error) => void,
|
||||||
|
): void {
|
||||||
|
const previous = linuxVisibleOverlayOwnerBindingQueues.get(window) ?? Promise.resolve();
|
||||||
|
const operation = previous
|
||||||
|
.catch(() => {})
|
||||||
|
.then(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (window.isDestroyed()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
execFile('xprop', args, { timeout: 1500 }, (error) => {
|
||||||
|
if (error) {
|
||||||
|
onError?.(error);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const queued = operation.finally(() => {
|
||||||
|
if (linuxVisibleOverlayOwnerBindingQueues.get(window) === queued) {
|
||||||
|
linuxVisibleOverlayOwnerBindingQueues.delete(window);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
linuxVisibleOverlayOwnerBindingQueues.set(window, queued);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVisibleOverlayX11OwnerBinding(window: BrowserWindow): void {
|
||||||
|
if (window.isDestroyed()) return;
|
||||||
|
enqueueVisibleOverlayX11OwnerBindingOperation(window, [
|
||||||
|
'-id',
|
||||||
|
getNativeWindowHandleDecimal(window),
|
||||||
|
'-remove',
|
||||||
|
'WM_TRANSIENT_FOR',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWindowsOverlayBindTargetHandle(
|
||||||
|
targetMpvSocketPath?: string | null,
|
||||||
|
): number | null {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targetMpvSocketPath) {
|
||||||
|
const windowTracker = deps.getWindowTracker() as {
|
||||||
|
getTargetWindowHandle?: () => number | null;
|
||||||
|
} | null;
|
||||||
|
const trackedHandle = windowTracker?.getTargetWindowHandle?.();
|
||||||
|
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
|
||||||
|
return trackedHandle;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return findWindowsMpvTargetWindowHandle();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOverlayWindowTracker(
|
||||||
|
override?: string | null,
|
||||||
|
targetMpvSocketPath?: string | null,
|
||||||
|
) {
|
||||||
|
const initialArgs = deps.getInitialArgs();
|
||||||
|
if (initialArgs && isHeadlessInitialCommand(initialArgs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createWindowTrackerCore(override, targetMpvSocketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindVisibleOverlayOwner(): void {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
deps.bindVisibleOverlayToTrackedX11Window(mainWindow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||||
|
const targetSocketPath = deps.getMpvSocketPath();
|
||||||
|
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath);
|
||||||
|
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetSocketPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tracker = deps.getWindowTracker();
|
||||||
|
const mpvResult = tracker
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
const win32 =
|
||||||
|
require('../../window-trackers/win32') as typeof import('../../window-trackers/win32');
|
||||||
|
const poll = win32.findMpvWindows();
|
||||||
|
const focused = poll.matches.find((m) => m.isForeground);
|
||||||
|
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
if (!mpvResult) return;
|
||||||
|
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
|
||||||
|
logger.warn('Failed to set overlay owner via koffi');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseVisibleOverlayOwner(): void {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
clearVisibleOverlayX11OwnerBinding(mainWindow);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||||
|
if (!clearWindowsOverlayOwner(overlayHwnd)) {
|
||||||
|
logger.warn('Failed to clear overlay owner via koffi');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOverlayWindowTrackerForCurrentSocket(): void {
|
||||||
|
startOverlayWindowTrackerCore({
|
||||||
|
backendOverride: deps.getBackendOverride(),
|
||||||
|
getMpvSocketPath: () => deps.getMpvSocketPath(),
|
||||||
|
createWindowTracker: createOverlayWindowTracker,
|
||||||
|
setWindowTracker: (tracker) => {
|
||||||
|
deps.setWindowTracker(tracker);
|
||||||
|
},
|
||||||
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||||
|
deps.updateVisibleOverlayBounds(geometry),
|
||||||
|
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||||
|
refreshCurrentSubtitle: () => {
|
||||||
|
deps.refreshCurrentSubtitle();
|
||||||
|
},
|
||||||
|
getOverlayWindows: () => deps.getOverlayWindows(),
|
||||||
|
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||||
|
bindOverlayOwner: () => bindVisibleOverlayOwner(),
|
||||||
|
releaseOverlayOwner: () => releaseVisibleOverlayOwner(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function retargetOverlayWindowTrackerForMpvSocket(
|
||||||
|
nextSocketPath: string,
|
||||||
|
previousSocketPath: string,
|
||||||
|
): void {
|
||||||
|
if (nextSocketPath === previousSocketPath || !deps.getOverlayRuntimeInitialized()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousTracker = deps.getWindowTracker();
|
||||||
|
if (previousTracker) {
|
||||||
|
try {
|
||||||
|
previousTracker.stop();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to stop previous overlay window tracker before retargeting', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseVisibleOverlayOwner();
|
||||||
|
deps.setWindowTracker(null);
|
||||||
|
deps.setTrackerNotReadyWarningShown(false);
|
||||||
|
deps.resetLastOverlayWindowGeometry();
|
||||||
|
startOverlayWindowTrackerForCurrentSocket();
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
deps.syncOverlayShortcuts();
|
||||||
|
logger.info(
|
||||||
|
`Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (
|
||||||
|
!mainWindow ||
|
||||||
|
mainWindow.isDestroyed() ||
|
||||||
|
!mainWindow.isVisible() ||
|
||||||
|
!overlayManager.getVisibleOverlayVisible()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowTracker = deps.getWindowTracker();
|
||||||
|
if (!windowTracker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||||
|
windowTracker.isTargetWindowMinimized()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||||
|
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(deps.getMpvSocketPath());
|
||||||
|
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
|
||||||
|
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWindowsVisibleOverlayZOrderSync(): void {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowsVisibleOverlayZOrderSyncInFlight) {
|
||||||
|
windowsVisibleOverlayZOrderSyncQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsVisibleOverlayZOrderSyncInFlight = true;
|
||||||
|
void syncWindowsVisibleOverlayToMpvZOrder()
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn('Failed to bind Windows overlay z-order to mpv', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
|
if (!windowsVisibleOverlayZOrderSyncQueued) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsVisibleOverlayZOrderSyncQueued = false;
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearWindowsVisibleOverlayZOrderRetryTimeouts();
|
||||||
|
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) {
|
||||||
|
const retryTimeout = setTimeout(() => {
|
||||||
|
windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter(
|
||||||
|
(timeout) => timeout !== retryTimeout,
|
||||||
|
);
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
}, delayMs);
|
||||||
|
windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean {
|
||||||
|
return (
|
||||||
|
process.platform === 'win32' &&
|
||||||
|
lastWindowsVisibleOverlayBlurredAtMs > 0 &&
|
||||||
|
Date.now() - lastWindowsVisibleOverlayBlurredAtMs <=
|
||||||
|
WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean {
|
||||||
|
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowTracker = deps.getWindowTracker();
|
||||||
|
if (!windowTracker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||||
|
windowTracker.isTargetWindowMinimized()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayFocused = mainWindow.isFocused();
|
||||||
|
const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false;
|
||||||
|
return !overlayFocused && !trackerFocused;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePollWindowsVisibleOverlayForegroundProcess(): void {
|
||||||
|
if (!shouldPollWindowsVisibleOverlayForegroundProcess()) {
|
||||||
|
lastWindowsVisibleOverlayForegroundProcessName = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processName = getWindowsForegroundProcessName();
|
||||||
|
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
|
||||||
|
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
|
||||||
|
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
|
||||||
|
|
||||||
|
if (normalizedProcessName !== previousProcessName) {
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') {
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||||
|
if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
||||||
|
maybePollWindowsVisibleOverlayForegroundProcess();
|
||||||
|
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||||
|
if (windowsVisibleOverlayForegroundPollInterval === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(windowsVisibleOverlayForegroundPollInterval);
|
||||||
|
windowsVisibleOverlayForegroundPollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||||
|
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||||
|
}
|
||||||
|
startMacOSVisibleOverlayForegroundProbe();
|
||||||
|
clearVisibleOverlayBlurRefreshTimeouts();
|
||||||
|
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||||
|
const refreshTimeout = setTimeout(() => {
|
||||||
|
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
|
||||||
|
(timeout) => timeout !== refreshTimeout,
|
||||||
|
);
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
}, delayMs);
|
||||||
|
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||||
|
|
||||||
|
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
||||||
|
|
||||||
|
function getLinuxOverlayPointerMeasurement() {
|
||||||
|
const measurement = overlayContentMeasurementStore.getLatestByLayer('visible');
|
||||||
|
return mapOverlayMeasurementForPointerInteraction(measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSuspendLinuxOverlayPointerInteraction(): boolean {
|
||||||
|
return deps.getModalInputExclusive() || deps.getStatsOverlayVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSuppressLinuxOverlayPointerInteraction(): boolean {
|
||||||
|
return resolveForegroundSuppressionWithGrace({
|
||||||
|
hasForegroundSeparateWindow: hasLiveSeparateWindow(
|
||||||
|
deps.getOverlayForegroundSeparateWindows(),
|
||||||
|
),
|
||||||
|
isTrackingMpvWindow: Boolean(deps.getWindowTracker()?.isTracking()),
|
||||||
|
isMpvWindowFocused: deps.getWindowTracker()?.isTargetWindowFocused?.() !== false,
|
||||||
|
isOverlayWindowFocused: overlayManager.getMainWindow()?.isFocused() === true,
|
||||||
|
nowMs: Date.now(),
|
||||||
|
graceMs: LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS,
|
||||||
|
state: linuxPointerForegroundSuppressionGrace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseLinuxOverlayInputShape(): boolean {
|
||||||
|
// Electron's setShape is a *bounding* shape: outside the given rects no pixels are drawn, so
|
||||||
|
// it clips the visible subtitle (and makes a dragged subtitle vanish behind the shaped
|
||||||
|
// region). There is no input-only region API on Linux, so selective hit-testing is handled by
|
||||||
|
// the main-process cursor poll instead. Keep this off to avoid clipping the overlay.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLinuxVisibleOverlayStartupInputGrace(): boolean {
|
||||||
|
return (
|
||||||
|
process.platform === 'linux' &&
|
||||||
|
linuxVisibleOverlayStartupInputGraceUntilMs > 0 &&
|
||||||
|
Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLinuxVisibleOverlayStartupInputGrace(): void {
|
||||||
|
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLinuxVisibleOverlayStartupInputPrimer(): void {
|
||||||
|
linuxVisibleOverlayStartupInputPrimed = false;
|
||||||
|
clearLinuxVisibleOverlayStartupInputGrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
|
||||||
|
if (!shouldUseLinuxOverlayInputShape()) {
|
||||||
|
linuxOverlayInputShapeActive = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = applyLinuxOverlayInputShape({
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||||
|
getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
|
||||||
|
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||||
|
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||||
|
});
|
||||||
|
linuxOverlayInputShapeActive = result.active;
|
||||||
|
return result.handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
|
||||||
|
visibleOverlayInteractionActive = active;
|
||||||
|
if (
|
||||||
|
process.platform === 'linux' &&
|
||||||
|
applyLinuxOverlayPointerInteractionMousePassthrough({
|
||||||
|
active,
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||||
|
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||||
|
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void {
|
||||||
|
if (process.platform !== 'linux') return;
|
||||||
|
if (linuxVisibleOverlayStartupInputPrimed) return;
|
||||||
|
if (shouldUseLinuxOverlayInputShape()) return;
|
||||||
|
if (
|
||||||
|
!shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||||
|
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||||
|
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
linuxVisibleOverlayStartupInputPrimed = true;
|
||||||
|
linuxVisibleOverlayStartupInputGraceUntilMs =
|
||||||
|
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
|
||||||
|
updateLinuxOverlayPointerInteractionActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linuxOverlayZOrderKeepAliveDeps = {
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
isTrackingMpvWindow: () => Boolean(deps.getWindowTracker()?.isTracking()),
|
||||||
|
isMpvWindowFocused: () => deps.getWindowTracker()?.isTargetWindowFocused?.() !== false,
|
||||||
|
isOverlayWindowFocused: () => overlayManager.getMainWindow()?.isFocused() === true,
|
||||||
|
shouldSuppressReassert: () =>
|
||||||
|
deps.getModalInputExclusive() ||
|
||||||
|
deps.getStatsOverlayVisible() ||
|
||||||
|
hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows()) ||
|
||||||
|
(visibleOverlayInteractionActive && overlayManager.getMainWindow()?.isFocused() !== true),
|
||||||
|
raiseMpvWindow: () => {
|
||||||
|
if (
|
||||||
|
lastLinuxVisibleOverlayFollowedMpvAtMs > 0 &&
|
||||||
|
Date.now() - lastLinuxVisibleOverlayFollowedMpvAtMs <=
|
||||||
|
LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
|
||||||
|
) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
lastLinuxVisibleOverlayFollowedMpvAtMs = Date.now();
|
||||||
|
return deps.getWindowTracker()?.raiseTargetWindow?.() ?? Promise.resolve(false);
|
||||||
|
},
|
||||||
|
releaseOverlayLayerOrder: () => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
mainWindow.setAlwaysOnTop(false);
|
||||||
|
mainWindow.setFullScreen?.(false);
|
||||||
|
mainWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
|
||||||
|
if (
|
||||||
|
deps.getLinuxVisibleOverlayWindowMode() === 'fullscreen-override' &&
|
||||||
|
mainWindow.isVisible()
|
||||||
|
) {
|
||||||
|
mainWindow.hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
deps.enforceOverlayLayerOrder();
|
||||||
|
},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isFocused()) return;
|
||||||
|
mainWindow.focus();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function requestLinuxOverlayZOrderFollow(): void {
|
||||||
|
if (!shouldRunLinuxOverlayZOrderKeepAlive()) return;
|
||||||
|
void tickLinuxOverlayZOrderKeepAlive(linuxOverlayZOrderKeepAliveDeps).catch((error) => {
|
||||||
|
logger.debug(
|
||||||
|
'Failed to follow tracked mpv behind focused overlay:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureLinuxOverlayZOrderKeepAliveLoop(linuxOverlayZOrderKeepAliveDeps);
|
||||||
|
|
||||||
|
const linuxOverlayPointerInteractionDeps = {
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
getCursorScreenPoint: () =>
|
||||||
|
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
|
||||||
|
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||||
|
getRendererInteractiveHint: () =>
|
||||||
|
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
|
||||||
|
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||||
|
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||||
|
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
|
||||||
|
getInteractionActive: () => visibleOverlayInteractionActive,
|
||||||
|
setInteractionActive: updateLinuxOverlayPointerInteractionActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
function tickLinuxOverlayPointerInteractionNow(): void {
|
||||||
|
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tickLinuxOverlayPointerInteraction(linuxOverlayPointerInteractionDeps);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureLinuxOverlayPointerInteractionLoop(linuxOverlayPointerInteractionDeps);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleStatsOverlayVisibilityChanged,
|
||||||
|
resetVisibleOverlayInputState,
|
||||||
|
restoreVisibleOverlayWindowShapeForShow,
|
||||||
|
startMacOSVisibleOverlayForegroundProbe,
|
||||||
|
getNativeWindowHandleDecimal,
|
||||||
|
getWindowsNativeWindowHandle,
|
||||||
|
getWindowsNativeWindowHandleNumber,
|
||||||
|
enqueueVisibleOverlayX11OwnerBindingOperation,
|
||||||
|
clearVisibleOverlayX11OwnerBinding,
|
||||||
|
createOverlayWindowTracker,
|
||||||
|
bindVisibleOverlayOwner,
|
||||||
|
releaseVisibleOverlayOwner,
|
||||||
|
startOverlayWindowTrackerForCurrentSocket,
|
||||||
|
retargetOverlayWindowTrackerForMpvSocket,
|
||||||
|
requestWindowsVisibleOverlayZOrderSync,
|
||||||
|
scheduleWindowsVisibleOverlayZOrderSyncBurst,
|
||||||
|
hasWindowsVisibleOverlayFocusHandoffGrace,
|
||||||
|
ensureWindowsVisibleOverlayForegroundPollLoop,
|
||||||
|
clearWindowsVisibleOverlayForegroundPollLoop,
|
||||||
|
scheduleVisibleOverlayBlurRefresh,
|
||||||
|
getLinuxOverlayPointerMeasurement,
|
||||||
|
hasLinuxVisibleOverlayStartupInputGrace,
|
||||||
|
clearLinuxVisibleOverlayStartupInputGrace,
|
||||||
|
resetLinuxVisibleOverlayStartupInputPrimer,
|
||||||
|
applyLinuxOverlayInputShapeFromLatestMeasurement,
|
||||||
|
updateLinuxOverlayPointerInteractionActive,
|
||||||
|
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
|
||||||
|
requestLinuxOverlayZOrderFollow,
|
||||||
|
tickLinuxOverlayPointerInteractionNow,
|
||||||
|
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||||
|
setVisibleOverlayInteractionActive: (active: boolean) => {
|
||||||
|
visibleOverlayInteractionActive = active;
|
||||||
|
},
|
||||||
|
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
|
||||||
|
getLastWindowsVisibleOverlayForegroundProcessName: () =>
|
||||||
|
lastWindowsVisibleOverlayForegroundProcessName,
|
||||||
|
getMacOSVisibleOverlayForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
|
||||||
|
setLinuxOverlayInteractiveHint: (interactive: boolean) => {
|
||||||
|
linuxOverlayInteractiveHint = interactive;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VisibleOverlayInteractionRuntime = ReturnType<
|
||||||
|
typeof createVisibleOverlayInteractionRuntime
|
||||||
|
>;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { detectWindowsMpvPluginRemovalCandidates } from './first-run-setup-plugin';
|
||||||
|
|
||||||
|
test('Windows plugin removal candidates include portable directory installs', () => {
|
||||||
|
const mpvPath = 'C:\\tools\\mpv\\mpv.exe';
|
||||||
|
const portablePluginDir = 'C:\\tools\\mpv\\portable_config\\scripts\\subminer';
|
||||||
|
const existing = new Set([portablePluginDir]);
|
||||||
|
|
||||||
|
const candidates = detectWindowsMpvPluginRemovalCandidates({
|
||||||
|
homeDir: 'C:\\Users\\tester',
|
||||||
|
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||||
|
mpvExecutablePath: mpvPath,
|
||||||
|
existsSync: (candidate) => existing.has(candidate),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(candidates, [{ path: portablePluginDir, kind: 'directory' }]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { app, dialog, shell } from 'electron';
|
||||||
|
import * as os from 'os';
|
||||||
|
import {
|
||||||
|
detectInstalledMpvPlugin,
|
||||||
|
detectWindowsMpvPluginRemovalCandidates,
|
||||||
|
removeLegacyMpvPluginCandidates,
|
||||||
|
resolvePackagedRuntimePluginPath,
|
||||||
|
} from './first-run-setup-plugin';
|
||||||
|
|
||||||
|
export interface WindowsMpvPluginDetectionRuntimeDeps {
|
||||||
|
mainDirname: string;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWindowsMpvPluginDetectionRuntime(
|
||||||
|
deps: WindowsMpvPluginDetectionRuntimeDeps,
|
||||||
|
): {
|
||||||
|
resolveBundledMpvRuntimePluginEntrypoint: () => string | undefined;
|
||||||
|
detectWindowsInstalledMpvPlugin: (
|
||||||
|
mpvExecutablePath: string,
|
||||||
|
) => ReturnType<typeof detectInstalledMpvPlugin>;
|
||||||
|
logInstalledMpvPluginDetected: (detection: {
|
||||||
|
path: string | null;
|
||||||
|
version: string | null;
|
||||||
|
}) => void;
|
||||||
|
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch: (
|
||||||
|
mpvPath: string,
|
||||||
|
detection: { path: string | null; version: string | null },
|
||||||
|
) => Promise<'removed' | 'continue' | 'cancel'>;
|
||||||
|
} {
|
||||||
|
function resolveBundledMpvRuntimePluginEntrypoint(): string | undefined {
|
||||||
|
return (
|
||||||
|
resolvePackagedRuntimePluginPath({
|
||||||
|
dirname: deps.mainDirname,
|
||||||
|
appPath: app.getAppPath(),
|
||||||
|
resourcesPath: process.resourcesPath,
|
||||||
|
}) ?? undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectWindowsInstalledMpvPlugin(mpvExecutablePath: string) {
|
||||||
|
return detectInstalledMpvPlugin({
|
||||||
|
platform: 'win32',
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
appDataDir: app.getPath('appData'),
|
||||||
|
mpvExecutablePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logInstalledMpvPluginDetected(detection: {
|
||||||
|
path: string | null;
|
||||||
|
version: string | null;
|
||||||
|
}) {
|
||||||
|
if (!detection.path) return;
|
||||||
|
deps.logWarn(
|
||||||
|
`SubMiner detected an installed mpv plugin at ${detection.path}. This mpv session will use the installed plugin. Remove it to use the bundled runtime plugin automatically. Detected plugin version: ${detection.version ?? 'unknown or legacy'}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(
|
||||||
|
mpvPath: string,
|
||||||
|
detection: { path: string | null; version: string | null },
|
||||||
|
): Promise<'removed' | 'continue' | 'cancel'> {
|
||||||
|
const response = await dialog.showMessageBox({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'SubMiner mpv plugin detected',
|
||||||
|
message: [
|
||||||
|
'SubMiner detected an installed mpv plugin at:',
|
||||||
|
detection.path ?? 'unknown path',
|
||||||
|
'',
|
||||||
|
"This mpv session will use the installed plugin unless it is removed. Remove it now to use SubMiner's bundled runtime plugin automatically.",
|
||||||
|
`Detected plugin version: ${detection.version ?? 'unknown or legacy'}`,
|
||||||
|
].join('\n'),
|
||||||
|
detail:
|
||||||
|
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash.',
|
||||||
|
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.response === 2) {
|
||||||
|
return 'cancel';
|
||||||
|
}
|
||||||
|
if (response.response === 1) {
|
||||||
|
return 'continue';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await removeLegacyMpvPluginCandidates({
|
||||||
|
candidates: detectWindowsMpvPluginRemovalCandidates({
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
appDataDir: app.getPath('appData'),
|
||||||
|
mpvExecutablePath: mpvPath,
|
||||||
|
}),
|
||||||
|
trashItem: (candidatePath) => shell.trashItem(candidatePath),
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
await dialog.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: 'Legacy mpv plugin removed',
|
||||||
|
message:
|
||||||
|
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
|
||||||
|
});
|
||||||
|
return 'removed';
|
||||||
|
}
|
||||||
|
|
||||||
|
await dialog.showMessageBox({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Could not remove legacy mpv plugin',
|
||||||
|
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
|
||||||
|
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
|
||||||
|
});
|
||||||
|
return 'cancel';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolveBundledMpvRuntimePluginEntrypoint,
|
||||||
|
detectWindowsInstalledMpvPlugin,
|
||||||
|
logInstalledMpvPluginDetected,
|
||||||
|
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildYomitanAnkiSettingsKey } from './yomitan-anki-server-sync';
|
||||||
|
|
||||||
|
test('buildYomitanAnkiSettingsKey includes force override policy', () => {
|
||||||
|
assert.notEqual(
|
||||||
|
buildYomitanAnkiSettingsKey({
|
||||||
|
targetUrl: 'http://127.0.0.1:8766',
|
||||||
|
targetDeck: 'Mining',
|
||||||
|
forceOverride: false,
|
||||||
|
}),
|
||||||
|
buildYomitanAnkiSettingsKey({
|
||||||
|
targetUrl: 'http://127.0.0.1:8766',
|
||||||
|
targetDeck: 'Mining',
|
||||||
|
forceOverride: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services';
|
||||||
|
import type { ResolvedConfig } from '../../types';
|
||||||
|
import {
|
||||||
|
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||||
|
shouldForceOverrideYomitanAnkiServer,
|
||||||
|
} from './yomitan-anki-server';
|
||||||
|
|
||||||
|
export interface YomitanAnkiServerSyncRuntimeDeps {
|
||||||
|
isExternalReadOnlyMode: () => boolean;
|
||||||
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
|
getYomitanParserRuntimeDeps: () => Parameters<typeof syncYomitanDefaultAnkiServerCore>[1];
|
||||||
|
logError: (message: string, ...args: unknown[]) => void;
|
||||||
|
logInfo: (message: string, ...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildYomitanAnkiSettingsKey(options: {
|
||||||
|
targetUrl: string;
|
||||||
|
targetDeck: string;
|
||||||
|
forceOverride: boolean;
|
||||||
|
}): string {
|
||||||
|
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): {
|
||||||
|
syncYomitanDefaultProfileAnkiServer: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
let lastSyncedYomitanAnkiSettingsKey: string | null = null;
|
||||||
|
|
||||||
|
function getPreferredYomitanAnkiServerUrl(): string {
|
||||||
|
return getPreferredYomitanAnkiServerUrlRuntime(deps.getResolvedConfig().ankiConnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||||
|
if (deps.isExternalReadOnlyMode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
||||||
|
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||||
|
const targetDeck = ankiConnectConfig?.deck?.trim() ?? '';
|
||||||
|
const forceOverride = ankiConnectConfig
|
||||||
|
? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig)
|
||||||
|
: false;
|
||||||
|
const targetSettingsKey = buildYomitanAnkiSettingsKey({
|
||||||
|
targetUrl,
|
||||||
|
targetDeck,
|
||||||
|
forceOverride,
|
||||||
|
});
|
||||||
|
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const synced = await syncYomitanDefaultAnkiServerCore(
|
||||||
|
targetUrl,
|
||||||
|
deps.getYomitanParserRuntimeDeps(),
|
||||||
|
{
|
||||||
|
error: (message, ...args) => {
|
||||||
|
deps.logError(message, ...args);
|
||||||
|
},
|
||||||
|
info: (message, ...args) => {
|
||||||
|
deps.logInfo(message, ...args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
forceOverride,
|
||||||
|
deck: targetDeck,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (synced) {
|
||||||
|
lastSyncedYomitanAnkiSettingsKey = targetSettingsKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { syncYomitanDefaultProfileAnkiServer };
|
||||||
|
}
|
||||||
@@ -123,6 +123,10 @@ test('stats daemon control stops live daemon and treats stale state as success',
|
|||||||
calls.push(`isProcessAlive:${pid}:${aliveChecks}`);
|
calls.push(`isProcessAlive:${pid}:${aliveChecks}`);
|
||||||
return aliveChecks === 1;
|
return aliveChecks === 1;
|
||||||
},
|
},
|
||||||
|
verifyProcessIdentity: (pid, startedAtMs) => {
|
||||||
|
calls.push(`verifyProcessIdentity:${pid}:${startedAtMs}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
resolveUrl: (state) => `http://127.0.0.1:${state.port}`,
|
resolveUrl: (state) => `http://127.0.0.1:${state.port}`,
|
||||||
spawnDaemon: async () => 1,
|
spawnDaemon: async () => 1,
|
||||||
waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
||||||
@@ -147,6 +151,7 @@ test('stats daemon control stops live daemon and treats stale state as success',
|
|||||||
assert.equal(exitCode, 0);
|
assert.equal(exitCode, 0);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'isProcessAlive:4242:1',
|
'isProcessAlive:4242:1',
|
||||||
|
'verifyProcessIdentity:4242:1',
|
||||||
'killProcess:4242:SIGTERM',
|
'killProcess:4242:SIGTERM',
|
||||||
'isProcessAlive:4242:2',
|
'isProcessAlive:4242:2',
|
||||||
'removeState',
|
'removeState',
|
||||||
@@ -158,3 +163,47 @@ test('stats daemon control stops live daemon and treats stale state as success',
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stats daemon control clears stale state when daemon identity mismatches', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const responses: Array<{ path: string; payload: { ok: boolean; url?: string; error?: string } }> =
|
||||||
|
[];
|
||||||
|
const handler = createRunStatsDaemonControlHandler({
|
||||||
|
statePath: '/tmp/stats-daemon.json',
|
||||||
|
readState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }),
|
||||||
|
removeState: () => {
|
||||||
|
calls.push('removeState');
|
||||||
|
},
|
||||||
|
isProcessAlive: (pid) => {
|
||||||
|
calls.push(`isProcessAlive:${pid}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
verifyProcessIdentity: (pid, startedAtMs) => {
|
||||||
|
calls.push(`verifyProcessIdentity:${pid}:${startedAtMs}`);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
resolveUrl: (state) => `http://127.0.0.1:${state.port}`,
|
||||||
|
spawnDaemon: async () => 1,
|
||||||
|
waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
||||||
|
openExternal: async () => {},
|
||||||
|
writeResponse: (responsePath, payload) => {
|
||||||
|
responses.push({ path: responsePath, payload });
|
||||||
|
},
|
||||||
|
killProcess: () => {
|
||||||
|
calls.push('killProcess');
|
||||||
|
},
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await handler({
|
||||||
|
action: 'stop',
|
||||||
|
responsePath: '/tmp/response.json',
|
||||||
|
openBrowser: false,
|
||||||
|
daemonScriptPath: '/tmp/stats-daemon-runner.js',
|
||||||
|
userDataPath: '/tmp/SubMiner',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(exitCode, 0);
|
||||||
|
assert.deepEqual(calls, ['isProcessAlive:4242', 'verifyProcessIdentity:4242:1', 'removeState']);
|
||||||
|
assert.deepEqual(responses, [{ path: '/tmp/response.json', payload: { ok: true } }]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BackgroundStatsServerState } from './main/runtime/stats-daemon';
|
import type { BackgroundStatsServerState } from './main/runtime/stats-daemon';
|
||||||
|
import { verifyBackgroundStatsServerIdentity } from './main/runtime/stats-daemon';
|
||||||
import type { StatsCliCommandResponse } from './main/runtime/stats-cli-command';
|
import type { StatsCliCommandResponse } from './main/runtime/stats-cli-command';
|
||||||
|
|
||||||
export type StatsDaemonControlAction = 'start' | 'stop';
|
export type StatsDaemonControlAction = 'start' | 'stop';
|
||||||
@@ -22,6 +23,7 @@ export function createRunStatsDaemonControlHandler(deps: {
|
|||||||
readState: () => BackgroundStatsServerState | null;
|
readState: () => BackgroundStatsServerState | null;
|
||||||
removeState: () => void;
|
removeState: () => void;
|
||||||
isProcessAlive: (pid: number) => boolean;
|
isProcessAlive: (pid: number) => boolean;
|
||||||
|
verifyProcessIdentity?: (pid: number, startedAtMs: number) => boolean;
|
||||||
resolveUrl: (state: Pick<BackgroundStatsServerState, 'port'>) => string;
|
resolveUrl: (state: Pick<BackgroundStatsServerState, 'port'>) => string;
|
||||||
spawnDaemon: (options: SpawnStatsDaemonOptions) => Promise<number> | number;
|
spawnDaemon: (options: SpawnStatsDaemonOptions) => Promise<number> | number;
|
||||||
waitForDaemonResponse: (responsePath: string) => Promise<StatsCliCommandResponse>;
|
waitForDaemonResponse: (responsePath: string) => Promise<StatsCliCommandResponse>;
|
||||||
@@ -81,6 +83,12 @@ export function createRunStatsDaemonControlHandler(deps: {
|
|||||||
writeResponseSafe(args.responsePath, { ok: true });
|
writeResponseSafe(args.responsePath, { ok: true });
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
const verifyProcessIdentity = deps.verifyProcessIdentity ?? verifyBackgroundStatsServerIdentity;
|
||||||
|
if (!verifyProcessIdentity(state.pid, state.startedAtMs)) {
|
||||||
|
deps.removeState();
|
||||||
|
writeResponseSafe(args.responsePath, { ok: true });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
deps.killProcess(state.pid, 'SIGTERM');
|
deps.killProcess(state.pid, 'SIGTERM');
|
||||||
const deadline = Date.now() + 2_000;
|
const deadline = Date.now() + 2_000;
|
||||||
|
|||||||
Reference in New Issue
Block a user