mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-12 15:13:32 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
05ac3a0382
|
|||
|
2c5a803839
|
|||
|
572bdd1cf7
|
|||
|
b9fe555b94
|
|||
|
8f362063dd
|
|||
|
eb1af727bb
|
|||
|
1fc83a842d
|
|||
|
a4edf53d21
|
|||
|
1a3944aa4f
|
|||
|
2d1b6cb78e
|
|||
|
0ef95cde09
|
@@ -45,6 +45,7 @@ export interface MpvRuntimeClientLike {
|
||||
playNextSubtitle?: () => void;
|
||||
setSubVisibility?: (visible: boolean) => void;
|
||||
setSecondarySubVisibility?: (visible: boolean) => void;
|
||||
setCurrentSecondarySubText?: (text: string) => void;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const source = readMainSource();
|
||||
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', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
const dismissBlock = source.match(
|
||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(dismissBlock);
|
||||
assert.match(
|
||||
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', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||
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;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
@@ -157,13 +161,14 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
||||
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
||||
);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') <
|
||||
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'),
|
||||
actionBlock.indexOf('deps.initSubtitlePrefetch(') <
|
||||
actionBlock.indexOf('deps.setActiveParsedSubtitleMediaPath(nextMediaPath);'),
|
||||
);
|
||||
});
|
||||
|
||||
test('update overlay notification action triggers install flow', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
@@ -173,13 +178,16 @@ test('update overlay notification action triggers install flow', () => {
|
||||
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
||||
assert.match(source, /installWhenAvailable:\s*true/);
|
||||
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
||||
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/);
|
||||
assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/);
|
||||
assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/);
|
||||
assert.match(
|
||||
source,
|
||||
runtimeSource,
|
||||
/deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/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', () => {
|
||||
@@ -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', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||
const actionBlock = source.match(
|
||||
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
||||
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
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', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/stats-server-runtime.ts');
|
||||
const startStatsServerBlock = source.match(
|
||||
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||
)?.groups?.body;
|
||||
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;
|
||||
|
||||
assert.ok(addYomitanNoteBlock);
|
||||
assert.match(
|
||||
addYomitanNoteBlock,
|
||||
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/,
|
||||
/const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/,
|
||||
);
|
||||
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||
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', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||
const actionBlock = source.match(
|
||||
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const resetBlock = source.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
const resetBlock = runtimeSource.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
@@ -459,17 +468,17 @@ test('manual visible overlay hide dismisses loading OSD', () => {
|
||||
});
|
||||
|
||||
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(
|
||||
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
||||
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
const statusBlock = source.match(
|
||||
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(readinessBlock);
|
||||
assert.ok(statusBlock);
|
||||
assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/);
|
||||
assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/);
|
||||
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
|
||||
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
|
||||
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', () => {
|
||||
const source = readMainSource();
|
||||
const resetBlock = source.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||
const resetBlock = runtimeSource.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
const setBlock = source.match(
|
||||
/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.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/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', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/overlay-geometry-runtime.ts');
|
||||
const afterBoundsBlock = source.match(
|
||||
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
||||
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(afterBoundsBlock);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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];
|
||||
if (value && !value.startsWith('--')) {
|
||||
resolved = value.trim();
|
||||
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,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { 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');
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
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();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
return {
|
||||
getCurrentAutoplayMediaPath,
|
||||
resetAutoplaySubtitlePrime,
|
||||
primeCurrentSubtitleForAutoplay,
|
||||
primeCurrentSubtitleForVisibleOverlay,
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
||||
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
||||
primeAutoplaySubtitleFromParsedCues,
|
||||
clearScheduledSubtitlePrefetchRefresh,
|
||||
refreshSubtitleSidebarFromSource,
|
||||
scheduleSubtitlePrefetchRefresh,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
detectInstalledMpvPlugin,
|
||||
filterLegacyMpvPluginFileCandidates,
|
||||
removeLegacyMpvPluginCandidates,
|
||||
resolvePackagedFirstRunPluginAssets,
|
||||
resolvePackagedRuntimePluginPath,
|
||||
@@ -220,6 +221,20 @@ test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without v
|
||||
});
|
||||
});
|
||||
|
||||
test('filterLegacyMpvPluginFileCandidates keeps only legacy file candidates', () => {
|
||||
assert.deepEqual(
|
||||
filterLegacyMpvPluginFileCandidates([
|
||||
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
|
||||
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
|
||||
{ path: '/tmp/mpv/scripts/subminer-loader.lua', kind: 'file' },
|
||||
]),
|
||||
[
|
||||
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
|
||||
{ path: '/tmp/mpv/scripts/subminer-loader.lua', kind: 'file' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await removeLegacyMpvPluginCandidates({
|
||||
|
||||
@@ -180,6 +180,12 @@ export function detectInstalledFirstRunPluginCandidates(options: {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function filterLegacyMpvPluginFileCandidates(
|
||||
candidates: InstalledFirstRunPluginCandidate[],
|
||||
): InstalledFirstRunPluginCandidate[] {
|
||||
return candidates.filter((candidate) => candidate.kind === 'file');
|
||||
}
|
||||
|
||||
function parseInstalledPluginVersion(content: string): string | null {
|
||||
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
|
||||
return match?.[1] ?? null;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildFfmpegSubtitleExtractionArgs } 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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isInteger(parsed) ? 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,
|
||||
): 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) => {
|
||||
const child = spawn(
|
||||
ffmpegPath,
|
||||
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
|
||||
);
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
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,62 @@
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import * as os from 'os';
|
||||
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}`,
|
||||
);
|
||||
void dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'SubMiner logs exported',
|
||||
message: 'SubMiner log export created.',
|
||||
detail: result.zipPath,
|
||||
buttons: ['OK', 'Show in Folder'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.response === 1) {
|
||||
shell.showItemInFolder(result.zipPath);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = describeUnknownError(error);
|
||||
deps.logWarn('Failed to export logs from tray.', error);
|
||||
void dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'SubMiner log export failed',
|
||||
message: 'Could not export SubMiner logs.',
|
||||
detail: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,37 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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) => 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) {
|
||||
sendMpvCommandRuntime(mpvClient, ['script-message', 'subminer-reload-session-bindings']);
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
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,
|
||||
readBackgroundStatsServerState,
|
||||
removeBackgroundStatsServerState,
|
||||
resolveBackgroundStatsServerUrl,
|
||||
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;
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
function readLiveBackgroundStatsDaemonState(): {
|
||||
pid: number;
|
||||
port: number;
|
||||
startedAtMs: number;
|
||||
} | null {
|
||||
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
||||
if (!state) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
if (state.pid === process.pid && !statsServer) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function clearOwnedBackgroundStatsDaemonState(): void {
|
||||
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
||||
if (state?.pid === process.pid) {
|
||||
removeBackgroundStatsServerState(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: () => readBackgroundStatsServerState(statsDaemonStatePath),
|
||||
removeBackgroundState: () => {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
},
|
||||
isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(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 = readBackgroundStatsServerState(statsDaemonStatePath);
|
||||
if (!state) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(state.pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
|
||||
removeBackgroundStatsServerState(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 (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
||||
removeBackgroundStatsServerState(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,125 @@
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
detectInstalledMpvPlugin,
|
||||
filterLegacyMpvPluginFileCandidates,
|
||||
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: filterLegacyMpvPluginFileCandidates(
|
||||
detectInstalledFirstRunPluginCandidates({
|
||||
platform: 'win32',
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user