Compare commits

...

11 Commits

Author SHA1 Message Date
sudacode 05ac3a0382 test: address runtime nitpick coverage 2026-06-12 01:46:48 -07:00
sudacode 2c5a803839 fix(main): remove obsolete subtitle delay handler wiring 2026-06-12 01:25:26 -07:00
sudacode 572bdd1cf7 fix: address CodeRabbit review findings across runtime modules
- Extract filterLegacyMpvPluginFileCandidates, buildYomitanAnkiSettingsKey, setMpvCurrentSecondarySubText, runSupportAssetUpdatesForLauncherResult helpers
- Include forceOverride in yomitan anki settings cache key (was missing, causing incorrect cache hits)
- Detect same-PID stale stats daemon state to avoid self-connect
- Validate non-empty extension in buildFfmpegSubtitleExtractionArgs
- Drop unused message param from showOverlayLoadingStatusNotification
- Log and rethrow on session bindings artifact write failure
- Add unit tests for all extracted helpers
2026-06-12 01:22:20 -07:00
sudacode b9fe555b94 refactor(main): extract visible-overlay platform interaction runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode 8f362063dd refactor(main): extract autoplay subtitle priming runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode eb1af727bb refactor(main): extract overlay geometry runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode 1fc83a842d refactor(main): extract overlay notifications runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode a4edf53d21 refactor(main): extract stats server runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode 1a3944aa4f refactor(main): extract password-store args, mpv plugin detection, yomitan anki sync, session bindings, log export from main.ts 2026-06-12 01:22:20 -07:00
sudacode 2d1b6cb78e refactor(main): extract internal subtitle extraction from main.ts 2026-06-12 01:22:20 -07:00
sudacode 0ef95cde09 refactor(main): extract update service runtime from main.ts 2026-06-12 01:18:40 -07:00
24 changed files with 3184 additions and 2001 deletions
+1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+38 -27
View File
@@ -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);
+39
View File
@@ -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 });
},
};
}
+62
View File
@@ -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);
});
+260
View File
@@ -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 };
}