mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { sendAppControlCommand } from '../../shared/app-control-client';
|
||||
import { startAppControlServer } from './app-control-server';
|
||||
|
||||
async function waitForSocketPath(socketPath: string): Promise<void> {
|
||||
const timeoutMs = 1000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(socketPath)) return;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`Timed out waiting for control socket ${socketPath} after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
test('app control server dispatches argv requests and replies ok', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
|
||||
const socketPath = path.join(dir, 'control.sock');
|
||||
const received: string[][] = [];
|
||||
const server = startAppControlServer({
|
||||
socketPath,
|
||||
platform: 'linux',
|
||||
handleArgv: (argv) => {
|
||||
received.push(argv);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForSocketPath(socketPath);
|
||||
const result = await sendAppControlCommand(['--start', '--socket', '/tmp/mpv.sock'], {
|
||||
socketPath,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: true });
|
||||
assert.deepEqual(received, [['--start', '--socket', '/tmp/mpv.sock']]);
|
||||
} finally {
|
||||
server.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('app control server rejects requests larger than 64KB by UTF-8 byte length', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
|
||||
const socketPath = path.join(dir, 'control.sock');
|
||||
const received: string[][] = [];
|
||||
const server = startAppControlServer({
|
||||
socketPath,
|
||||
platform: 'linux',
|
||||
handleArgv: (argv) => {
|
||||
received.push(argv);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForSocketPath(socketPath);
|
||||
const result = await sendAppControlCommand(
|
||||
Array.from({ length: 4 }, () => 'あ'.repeat(6000)),
|
||||
{
|
||||
socketPath,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { ok: false, error: 'App control request too large' });
|
||||
assert.deepEqual(received, []);
|
||||
} finally {
|
||||
server.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('app control server logs and closes errored client sockets', () => {
|
||||
const originalCreateServer = net.createServer;
|
||||
let socketHandler: ((socket: net.Socket) => void) | null = null;
|
||||
const fakeServer = new EventEmitter() as net.Server;
|
||||
fakeServer.listen = (() => fakeServer) as net.Server['listen'];
|
||||
fakeServer.close = ((callback?: (err?: Error) => void) => {
|
||||
callback?.();
|
||||
return fakeServer;
|
||||
}) as net.Server['close'];
|
||||
const received: string[][] = [];
|
||||
const warnings: Array<{ message: string; error?: unknown }> = [];
|
||||
|
||||
try {
|
||||
net.createServer = ((handler?: (socket: net.Socket) => void) => {
|
||||
socketHandler = handler ?? null;
|
||||
return fakeServer;
|
||||
}) as typeof net.createServer;
|
||||
|
||||
const server = startAppControlServer({
|
||||
socketPath: '\\\\.\\pipe\\subminer-test-control',
|
||||
platform: 'win32',
|
||||
handleArgv: (argv) => {
|
||||
received.push(argv);
|
||||
},
|
||||
logWarn: (message, error) => {
|
||||
warnings.push({ message, error });
|
||||
},
|
||||
});
|
||||
|
||||
const error = new Error('client reset');
|
||||
let destroyed = false;
|
||||
const socket = new EventEmitter() as net.Socket;
|
||||
socket.destroy = (() => {
|
||||
destroyed = true;
|
||||
return socket;
|
||||
}) as net.Socket['destroy'];
|
||||
|
||||
const handler = socketHandler as ((socket: net.Socket) => void) | null;
|
||||
assert.ok(handler);
|
||||
handler(socket);
|
||||
socket.emit('error', error);
|
||||
socket.emit('data', Buffer.from('{"argv":["--start"]}\n'));
|
||||
|
||||
assert.equal(destroyed, true);
|
||||
assert.deepEqual(received, []);
|
||||
assert.deepEqual(warnings, [{ message: 'App control client socket error.', error }]);
|
||||
|
||||
server.close();
|
||||
} finally {
|
||||
net.createServer = originalCreateServer;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
encodeAppControlResponse,
|
||||
parseAppControlRequestLine,
|
||||
type AppControlResponse,
|
||||
} from '../../shared/app-control';
|
||||
|
||||
export interface AppControlServerOptions {
|
||||
socketPath: string;
|
||||
platform?: NodeJS.Platform;
|
||||
handleArgv: (argv: string[]) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
logWarn?: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export interface AppControlServerHandle {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function prepareSocketPath(socketPath: string, platform: NodeJS.Platform): void {
|
||||
if (platform === 'win32') return;
|
||||
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
}
|
||||
|
||||
function cleanupSocketPath(socketPath: string, platform: NodeJS.Platform): void {
|
||||
if (platform === 'win32') return;
|
||||
try {
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function writeResponse(socket: net.Socket, response: AppControlResponse): void {
|
||||
socket.end(encodeAppControlResponse(response));
|
||||
}
|
||||
|
||||
export function startAppControlServer(options: AppControlServerOptions): AppControlServerHandle {
|
||||
const platform = options.platform ?? process.platform;
|
||||
prepareSocketPath(options.socketPath, platform);
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
let byteCount = 0;
|
||||
let handled = false;
|
||||
|
||||
socket.on('error', (error) => {
|
||||
if (handled) return;
|
||||
handled = true;
|
||||
options.logWarn?.('App control client socket error.', error);
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
if (handled) return;
|
||||
byteCount += chunk.length;
|
||||
buffer += chunk.toString('utf8');
|
||||
if (byteCount > 65536) {
|
||||
handled = true;
|
||||
writeResponse(socket, { ok: false, error: 'App control request too large' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newlineIndex = buffer.indexOf('\n');
|
||||
if (newlineIndex < 0) return;
|
||||
handled = true;
|
||||
|
||||
try {
|
||||
const request = parseAppControlRequestLine(buffer.slice(0, newlineIndex));
|
||||
options.handleArgv(request.argv);
|
||||
writeResponse(socket, { ok: true });
|
||||
} catch (error) {
|
||||
options.logWarn?.('Failed to handle app control command.', error);
|
||||
writeResponse(socket, {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
options.logWarn?.(`App control socket failed: ${options.socketPath}`, error);
|
||||
});
|
||||
server.listen(options.socketPath, () => {
|
||||
options.logDebug?.(`App control socket listening: ${options.socketPath}`);
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
close: () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
cleanupSocketPath(options.socketPath, platform);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
stopConfigHotReload: () => calls.push('stop-config'),
|
||||
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
isAppReady: () => true,
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
@@ -102,6 +103,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
isAppReady: () => true,
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
@@ -148,3 +150,51 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('cleanup deps builder skips global shortcut cleanup before app ready', () => {
|
||||
const calls: string[] = [];
|
||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
isAppReady: () => false,
|
||||
unregisterAllGlobalShortcuts: () => {
|
||||
throw new Error('globalShortcut cannot be used before the app is ready');
|
||||
},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
getMainOverlayWindow: () => null,
|
||||
clearMainOverlayWindow: () => {},
|
||||
getModalOverlayWindow: () => null,
|
||||
clearModalOverlayWindow: () => {},
|
||||
getYomitanParserWindow: () => null,
|
||||
clearYomitanParserState: () => {},
|
||||
getWindowTracker: () => null,
|
||||
flushMpvLog: () => {},
|
||||
getMpvSocket: () => null,
|
||||
getReconnectTimer: () => null,
|
||||
clearReconnectTimerRef: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getImmersionTracker: () => null,
|
||||
clearImmersionTracker: () => {},
|
||||
getAnkiIntegration: () => null,
|
||||
getAnilistSetupWindow: () => null,
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
getFirstRunSetupWindow: () => null,
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||
cleanup();
|
||||
|
||||
assert.deepEqual(calls, ['destroy-tray']);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
stopConfigHotReload: () => void;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
isAppReady: () => boolean;
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
@@ -63,7 +64,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
stopConfigHotReload: () => deps.stopConfigHotReload(),
|
||||
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
|
||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||
unregisterAllGlobalShortcuts: () => {
|
||||
if (!deps.isAppReady()) return;
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
},
|
||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
|
||||
@@ -143,3 +143,92 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let targetReady = false;
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
isSignalTargetReady: () => targetReady,
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
|
||||
targetReady = true;
|
||||
gate.flushPendingAutoplayReadySignal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'script-message'),
|
||||
[['script-message', 'subminer-autoplay-ready']],
|
||||
);
|
||||
assert.equal(
|
||||
commands.some(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate drops deferred readiness after media changes before flush', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let targetReady = false;
|
||||
let currentMediaPath = '/media/video-1.mkv';
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => currentMediaPath,
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
isSignalTargetReady: () => targetReady,
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
currentMediaPath = '/media/video-2.mkv';
|
||||
targetReady = true;
|
||||
gate.flushPendingAutoplayReadySignal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export type AutoplayReadyGateDeps = {
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
signalPluginAutoplayReady: () => void;
|
||||
isSignalTargetReady?: () => boolean;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logDebug: (message: string) => void;
|
||||
};
|
||||
@@ -21,12 +22,23 @@ export type AutoplayReadyGateDeps = {
|
||||
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
let autoPlayReadySignalMediaPath: string | null = null;
|
||||
let autoPlayReadySignalGeneration = 0;
|
||||
let pendingAutoplayReadySignal: {
|
||||
mediaPath: string;
|
||||
payload: SubtitleData;
|
||||
options?: { forceWhilePaused?: boolean };
|
||||
} | null = null;
|
||||
|
||||
const invalidatePendingAutoplayReadyFallbacks = (): void => {
|
||||
autoPlayReadySignalMediaPath = null;
|
||||
pendingAutoplayReadySignal = null;
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
};
|
||||
|
||||
const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true;
|
||||
|
||||
const getSignalMediaPath = (): string =>
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||
|
||||
const maybeSignalPluginAutoplayReady = (
|
||||
payload: SubtitleData,
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
@@ -39,8 +51,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaPath =
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||
const mediaPath = getSignalMediaPath();
|
||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||
const releaseRetryDelayMs = 200;
|
||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||
@@ -104,16 +115,42 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
};
|
||||
|
||||
if (duplicateMediaSignal) {
|
||||
pendingAutoplayReadySignal = null;
|
||||
return;
|
||||
}
|
||||
if (!isSignalTargetReady()) {
|
||||
pendingAutoplayReadySignal = { mediaPath, payload, options };
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAutoplayReadySignal = null;
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
};
|
||||
|
||||
const flushPendingAutoplayReadySignal = (): void => {
|
||||
if (!pendingAutoplayReadySignal || !isSignalTargetReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingSignal = pendingAutoplayReadySignal;
|
||||
pendingAutoplayReadySignal = null;
|
||||
if (getSignalMediaPath() !== pendingSignal.mediaPath) {
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] dropped deferred signal for stale media ${pendingSignal.mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options);
|
||||
};
|
||||
|
||||
return {
|
||||
flushPendingAutoplayReadySignal,
|
||||
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
|
||||
invalidatePendingAutoplayReadyFallbacks,
|
||||
maybeSignalPluginAutoplayReady,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||
|
||||
test('selectAutoplayStartupCue returns the active cue at the current time', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue(
|
||||
[
|
||||
{ startTime: 1, endTime: 3, text: 'first' },
|
||||
{ startTime: 4, endTime: 5, text: 'second' },
|
||||
],
|
||||
2,
|
||||
1,
|
||||
),
|
||||
{ startTime: 1, endTime: 3, text: 'first' },
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue returns the next imminent cue before playback starts', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue(
|
||||
[
|
||||
{ startTime: 1.2, endTime: 3, text: 'first' },
|
||||
{ startTime: 4, endTime: 5, text: 'second' },
|
||||
],
|
||||
0,
|
||||
2,
|
||||
),
|
||||
{ startTime: 1.2, endTime: 3, text: 'first' },
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue clamps negative current time to startup', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue([{ startTime: 0, endTime: 1, text: 'startup' }], -0.5, 0),
|
||||
{ startTime: 0, endTime: 1, text: 'startup' },
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue does not reveal far future subtitle text', () => {
|
||||
assert.equal(
|
||||
selectAutoplayStartupCue([{ startTime: 12, endTime: 15, text: 'later' }], 0, 2),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue skips blank cues', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue(
|
||||
[
|
||||
{ startTime: 0, endTime: 1, text: ' ' },
|
||||
{ startTime: 0.5, endTime: 2, text: 'visible' },
|
||||
],
|
||||
0.75,
|
||||
1,
|
||||
),
|
||||
{ startTime: 0.5, endTime: 2, text: 'visible' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { SubtitleCue } from '../../types';
|
||||
|
||||
export function selectAutoplayStartupCue(
|
||||
cues: SubtitleCue[],
|
||||
currentTimeSeconds: number,
|
||||
lookaheadSeconds: number,
|
||||
): SubtitleCue | null {
|
||||
const currentTime = Math.max(0, Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0);
|
||||
const lookahead = Math.max(0, Number.isFinite(lookaheadSeconds) ? lookaheadSeconds : 0);
|
||||
const latestStartTime = currentTime + lookahead;
|
||||
|
||||
for (const cue of cues) {
|
||||
if (!cue.text.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (cue.startTime <= currentTime && cue.endTime > currentTime) {
|
||||
return cue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const cue of cues) {
|
||||
if (!cue.text.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (cue.startTime >= currentTime && cue.startTime <= latestStartTime) {
|
||||
return cue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAutoplayTokenizationWarmRelease } from './autoplay-tokenization-warm-release';
|
||||
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('autoplay tokenization warm release signals immediately when warmups are ready', () => {
|
||||
const calls: string[] = [];
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => true,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
|
||||
assert.deepEqual(calls, ['signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release primes subtitles before waiting for warmups', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveWarmup!: () => void;
|
||||
const warmup = new Promise<void>((resolve) => {
|
||||
resolveWarmup = resolve;
|
||||
});
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => false,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
await warmup;
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
primeCurrentSubtitle: () => {
|
||||
calls.push('prime');
|
||||
},
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(calls, ['prime', 'warmup']);
|
||||
|
||||
resolveWarmup();
|
||||
await warmup;
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(calls, ['prime', 'warmup', 'signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release waits for subtitle priming before signaling ready media', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolvePrime!: () => void;
|
||||
const prime = new Promise<void>((resolve) => {
|
||||
resolvePrime = resolve;
|
||||
});
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => true,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
primeCurrentSubtitle: () => {
|
||||
calls.push('prime');
|
||||
return prime;
|
||||
},
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['prime']);
|
||||
|
||||
resolvePrime();
|
||||
await prime;
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['prime', 'signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release waits for warmups before signaling current media', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveWarmup!: () => void;
|
||||
const warmup = new Promise<void>((resolve) => {
|
||||
resolveWarmup = resolve;
|
||||
});
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => false,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
await warmup;
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(calls, ['warmup']);
|
||||
|
||||
resolveWarmup();
|
||||
await warmup;
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['warmup', 'signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release skips stale media after warmup resolves', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentMediaPath = '/tmp/video-2.mkv';
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => false,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
},
|
||||
getCurrentMediaPath: () => currentMediaPath,
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video-1.mkv');
|
||||
await Promise.resolve();
|
||||
currentMediaPath = '/tmp/video-3.mkv';
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['warmup']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release skips signaling when current media is cleared', () => {
|
||||
const calls: string[] = [];
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => true,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
},
|
||||
getCurrentMediaPath: () => null,
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
function normalizeMediaPath(mediaPath: string | null | undefined): string | null {
|
||||
if (typeof mediaPath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = mediaPath.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function createAutoplayTokenizationWarmRelease(deps: {
|
||||
isTokenizationWarmupReady: () => boolean;
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
getCurrentMediaPath: () => string | null | undefined;
|
||||
primeCurrentSubtitle?: (mediaPath: string) => void | Promise<void>;
|
||||
signalAutoplayReady: () => void;
|
||||
warn: (message: string, error: unknown) => void;
|
||||
}): (mediaPath: string | null | undefined) => void {
|
||||
const signalIfCurrent = (mediaPath: string): void => {
|
||||
const currentMediaPath = normalizeMediaPath(deps.getCurrentMediaPath());
|
||||
if (!currentMediaPath || currentMediaPath !== mediaPath) {
|
||||
return;
|
||||
}
|
||||
deps.signalAutoplayReady();
|
||||
};
|
||||
|
||||
const primeSubtitleForRelease = (mediaPath: string): Promise<void> | null => {
|
||||
if (!deps.primeCurrentSubtitle) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Promise.resolve(deps.primeCurrentSubtitle(mediaPath)).catch((error) => {
|
||||
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (mediaPath) => {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
if (!normalizedPath) {
|
||||
return;
|
||||
}
|
||||
const primePromise = primeSubtitleForRelease(normalizedPath);
|
||||
if (deps.isTokenizationWarmupReady()) {
|
||||
if (!primePromise) {
|
||||
signalIfCurrent(normalizedPath);
|
||||
return;
|
||||
}
|
||||
void primePromise.then(() => {
|
||||
signalIfCurrent(normalizedPath);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const warmupPromise = deps.startTokenizationWarmups();
|
||||
const readinessPromise = primePromise
|
||||
? Promise.all([primePromise, warmupPromise]).then(() => {})
|
||||
: warmupPromise;
|
||||
void readinessPromise
|
||||
.then(() => {
|
||||
signalIfCurrent(normalizedPath);
|
||||
})
|
||||
.catch((error) => {
|
||||
deps.warn('Startup tokenization warmup failed before autoplay readiness release:', error);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -137,10 +137,13 @@ export function composeMpvRuntimeHandlers<
|
||||
const shouldInitializeMecabForAnnotations = (): boolean => {
|
||||
const nPlusOneEnabled =
|
||||
options.tokenizer.buildTokenizerDepsMainDeps.getNPlusOneEnabled?.() !== false;
|
||||
const knownWordsEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled
|
||||
? options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled() !== false
|
||||
: nPlusOneEnabled;
|
||||
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
|
||||
const frequencyEnabled =
|
||||
options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false;
|
||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
};
|
||||
const shouldWarmupAnnotationDictionaries = (): boolean => {
|
||||
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
|
||||
|
||||
@@ -18,6 +18,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
isAppReady: () => true,
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const calls: string[] = [];
|
||||
const ankiPatches: Array<{ enabled: boolean }> = [];
|
||||
const ankiPatches: unknown[] = [];
|
||||
const sessionBindingWarnings: string[][] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
@@ -25,7 +25,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`),
|
||||
applyAnkiRuntimeConfigPatch: (patch) => {
|
||||
ankiPatches.push({ enabled: patch.ai });
|
||||
ankiPatches.push(patch);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
|
||||
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
|
||||
assert.ok(calls.includes('broadcast:config:hot-reload:object'));
|
||||
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
|
||||
assert.deepEqual(ankiPatches, [{ ai: config.ankiConnect.ai.enabled }]);
|
||||
assert.equal(sessionBindingWarnings.length, 1);
|
||||
assert.ok(
|
||||
sessionBindingWarnings[0]?.some((message) =>
|
||||
@@ -57,6 +57,87 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and logging changes', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.ankiConnect.behavior.autoUpdateNewCards = false;
|
||||
config.ankiConnect.knownWords.highlightEnabled = true;
|
||||
config.ankiConnect.knownWords.refreshMinutes = 90;
|
||||
config.ankiConnect.knownWords.decks = { Anime: ['Mining'] };
|
||||
config.ankiConnect.nPlusOne.enabled = true;
|
||||
config.ankiConnect.nPlusOne.minSentenceWords = 4;
|
||||
config.ankiConnect.fields.word = 'Expression';
|
||||
config.ankiConnect.fields.audio = 'SentenceAudioCustom';
|
||||
config.ankiConnect.fields.image = 'ScreenshotCustom';
|
||||
config.ankiConnect.fields.sentence = 'SentenceCustom';
|
||||
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
||||
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
||||
config.ankiConnect.isKiku.fieldGrouping = 'manual';
|
||||
config.logging.level = 'debug';
|
||||
const calls: string[] = [];
|
||||
const ankiPatches: unknown[] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => calls.push('set:keybindings'),
|
||||
setSessionBindings: () => calls.push('set:session-bindings'),
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||
setSecondarySubMode: () => calls.push('set:secondary'),
|
||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||
applyAnkiRuntimeConfigPatch: (patch) => {
|
||||
calls.push('anki:patch');
|
||||
ankiPatches.push(patch);
|
||||
},
|
||||
invalidateTokenizationCache: () => calls.push('invalidate:tokens'),
|
||||
refreshSubtitlePrefetch: () => calls.push('refresh:prefetch'),
|
||||
refreshCurrentSubtitle: () => calls.push('refresh:subtitle'),
|
||||
setLogLevel: (level) => calls.push(`log:${level}`),
|
||||
});
|
||||
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: [
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'ankiConnect.nPlusOne.enabled',
|
||||
'ankiConnect.nPlusOne.minSentenceWords',
|
||||
'ankiConnect.fields.word',
|
||||
'ankiConnect.fields.audio',
|
||||
'ankiConnect.fields.image',
|
||||
'ankiConnect.fields.sentence',
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
'logging.level',
|
||||
],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(ankiPatches, [
|
||||
{
|
||||
behavior: { autoUpdateNewCards: false },
|
||||
knownWords: config.ankiConnect.knownWords,
|
||||
nPlusOne: config.ankiConnect.nPlusOne,
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
audio: 'SentenceAudioCustom',
|
||||
image: 'ScreenshotCustom',
|
||||
sentence: 'SentenceCustom',
|
||||
miscInfo: 'MiscInfoCustom',
|
||||
},
|
||||
isLapis: { sentenceCardModel: 'Sentence Card Custom' },
|
||||
isKiku: { fieldGrouping: 'manual' },
|
||||
},
|
||||
]);
|
||||
assert.ok(calls.includes('invalidate:tokens'));
|
||||
assert.ok(calls.includes('refresh:prefetch'));
|
||||
assert.ok(calls.includes('refresh:subtitle'));
|
||||
assert.ok(calls.includes('log:debug'));
|
||||
assert.ok(calls.includes('broadcast:config:hot-reload'));
|
||||
});
|
||||
|
||||
test('buildConfigHotReloadPayload includes independent primary subtitle mode', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.subtitleStyle.primaryDefaultMode = 'hover';
|
||||
@@ -68,6 +149,48 @@ test('buildConfigHotReloadPayload includes independent primary subtitle mode', (
|
||||
assert.equal(payload.secondarySubMode, 'hidden');
|
||||
});
|
||||
|
||||
test('buildConfigHotReloadPayload reflects added, removed, and remapped session bindings', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.stats.markWatchedKey = 'Ctrl+Shift+KeyW';
|
||||
config.shortcuts.openJimaku = null;
|
||||
config.keybindings = [
|
||||
{ key: 'KeyF', command: null },
|
||||
{ key: 'Ctrl+Alt+KeyM', command: ['show-text', 'custom'] },
|
||||
];
|
||||
|
||||
const payload = buildConfigHotReloadPayload(config);
|
||||
|
||||
assert.equal(
|
||||
payload.sessionBindings.some(
|
||||
(binding) =>
|
||||
binding.sourcePath === 'stats.markWatchedKey' &&
|
||||
binding.originalKey === 'Ctrl+Shift+KeyW' &&
|
||||
binding.actionType === 'session-action' &&
|
||||
binding.actionId === 'markWatched',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
payload.sessionBindings.some(
|
||||
(binding) =>
|
||||
binding.originalKey === 'Ctrl+Alt+KeyM' &&
|
||||
binding.actionType === 'mpv-command' &&
|
||||
binding.command.join(' ') === 'show-text custom',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
payload.sessionBindings.some((binding) => binding.originalKey === 'KeyF'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
payload.sessionBindings.some(
|
||||
(binding) => binding.actionType === 'session-action' && binding.actionId === 'openJimaku',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { compileSessionBindings } from '../../core/services/session-bindings';
|
||||
import { resolveKeybindings } from '../../core/utils/keybindings';
|
||||
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
|
||||
import type { AnkiConnectConfig } from '../../types/anki';
|
||||
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
||||
|
||||
type ConfigHotReloadAppliedDeps = {
|
||||
@@ -14,9 +15,11 @@ type ConfigHotReloadAppliedDeps = {
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
applyAnkiRuntimeConfigPatch: (patch: {
|
||||
ai: ResolvedConfig['ankiConnect']['ai']['enabled'];
|
||||
}) => void;
|
||||
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) => void;
|
||||
invalidateTokenizationCache?: () => void;
|
||||
refreshSubtitlePrefetch?: () => void;
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
||||
};
|
||||
|
||||
type ConfigHotReloadMessageDeps = {
|
||||
@@ -30,8 +33,8 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
||||
}
|
||||
return {
|
||||
...config.subtitleStyle,
|
||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
||||
knownWordColor: config.ankiConnect.knownWords.color,
|
||||
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
|
||||
knownWordColor: config.subtitleStyle.knownWordColor,
|
||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||
enableJlpt: config.subtitleStyle.enableJlpt,
|
||||
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
|
||||
@@ -44,6 +47,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
|
||||
keybindings,
|
||||
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
|
||||
statsToggleKey: config.stats.toggleKey,
|
||||
statsMarkWatchedKey: config.stats.markWatchedKey,
|
||||
platform:
|
||||
process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux',
|
||||
rawConfig: config,
|
||||
@@ -59,6 +63,70 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
|
||||
};
|
||||
}
|
||||
|
||||
function hasAnyHotReloadField(diff: ConfigHotReloadDiff, prefixes: string[]): boolean {
|
||||
return diff.hotReloadFields.some((field) =>
|
||||
prefixes.some((prefix) => field === prefix || field.startsWith(`${prefix}.`)),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnkiRuntimeConfigPatch(
|
||||
diff: ConfigHotReloadDiff,
|
||||
config: ResolvedConfig,
|
||||
): Partial<AnkiConnectConfig> | null {
|
||||
const patch: Partial<AnkiConnectConfig> = {};
|
||||
|
||||
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
|
||||
patch.ai = config.ankiConnect.ai.enabled;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.ai.enabled')) {
|
||||
patch.ai = config.ankiConnect.ai.enabled;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) {
|
||||
patch.behavior = { autoUpdateNewCards: config.ankiConnect.behavior.autoUpdateNewCards };
|
||||
}
|
||||
if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) {
|
||||
patch.knownWords = config.ankiConnect.knownWords;
|
||||
}
|
||||
if (hasAnyHotReloadField(diff, ['ankiConnect.nPlusOne'])) {
|
||||
patch.nPlusOne = config.ankiConnect.nPlusOne;
|
||||
}
|
||||
const fieldPatch: NonNullable<AnkiConnectConfig['fields']> = {};
|
||||
if (diff.hotReloadFields.includes('ankiConnect.fields.word')) {
|
||||
fieldPatch.word = config.ankiConnect.fields.word;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.fields.audio')) {
|
||||
fieldPatch.audio = config.ankiConnect.fields.audio;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.fields.image')) {
|
||||
fieldPatch.image = config.ankiConnect.fields.image;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.fields.sentence')) {
|
||||
fieldPatch.sentence = config.ankiConnect.fields.sentence;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.fields.miscInfo')) {
|
||||
fieldPatch.miscInfo = config.ankiConnect.fields.miscInfo;
|
||||
}
|
||||
if (Object.keys(fieldPatch).length > 0) {
|
||||
patch.fields = fieldPatch;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.isLapis.sentenceCardModel')) {
|
||||
patch.isLapis = { sentenceCardModel: config.ankiConnect.isLapis.sentenceCardModel };
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
|
||||
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
|
||||
}
|
||||
|
||||
return Object.keys(patch).length > 0 ? patch : null;
|
||||
}
|
||||
|
||||
function hasAnnotationRuntimeHotReload(diff: ConfigHotReloadDiff): boolean {
|
||||
return hasAnyHotReloadField(diff, [
|
||||
'ankiConnect.knownWords',
|
||||
'ankiConnect.nPlusOne',
|
||||
'ankiConnect.fields.word',
|
||||
]);
|
||||
}
|
||||
|
||||
export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) {
|
||||
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
|
||||
const payload = buildConfigHotReloadPayload(config);
|
||||
@@ -74,8 +142,19 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
|
||||
deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai.enabled });
|
||||
const ankiPatch = buildAnkiRuntimeConfigPatch(diff, config);
|
||||
if (ankiPatch) {
|
||||
deps.applyAnkiRuntimeConfigPatch(ankiPatch);
|
||||
}
|
||||
|
||||
if (hasAnnotationRuntimeHotReload(diff)) {
|
||||
deps.invalidateTokenizationCache?.();
|
||||
deps.refreshSubtitlePrefetch?.();
|
||||
deps.refreshCurrentSubtitle?.();
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.includes('logging.level')) {
|
||||
deps.setLogLevel?.(config.logging.level);
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ConfigHotReloadRuntimeDeps,
|
||||
} from '../../core/services/config-hot-reload';
|
||||
import type { ReloadConfigStrictResult } from '../../config';
|
||||
import type { AnkiConnectConfig } from '../../types/anki';
|
||||
import type {
|
||||
ConfigHotReloadPayload,
|
||||
ConfigValidationWarning,
|
||||
@@ -69,9 +70,11 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
applyAnkiRuntimeConfigPatch: (patch: {
|
||||
ai: ResolvedConfig['ankiConnect']['ai']['enabled'];
|
||||
}) => void;
|
||||
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) => void;
|
||||
invalidateTokenizationCache?: () => void;
|
||||
refreshSubtitlePrefetch?: () => void;
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
||||
@@ -84,8 +87,12 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
|
||||
deps.broadcastToOverlayWindows(channel, payload),
|
||||
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) =>
|
||||
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) =>
|
||||
deps.applyAnkiRuntimeConfigPatch(patch),
|
||||
invalidateTokenizationCache: () => deps.invalidateTokenizationCache?.(),
|
||||
refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(),
|
||||
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
|
||||
setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ const fields: ConfigSettingsField[] = [
|
||||
label: 'Launch mode',
|
||||
description: 'Launch mode setting.',
|
||||
configPath: 'mpv.launchMode',
|
||||
category: 'playback-sources',
|
||||
section: 'mpv launcher',
|
||||
category: 'behavior',
|
||||
section: 'mpv Playback',
|
||||
control: 'select',
|
||||
defaultValue: 'windowed',
|
||||
restartBehavior: 'restart',
|
||||
|
||||
@@ -3,16 +3,17 @@ import path from 'node:path';
|
||||
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
|
||||
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
||||
import type {
|
||||
ConfigSettingsAnkiListResult,
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsSaveResult,
|
||||
ConfigSettingsSnapshot,
|
||||
} from '../../types/settings';
|
||||
import type { ReloadConfigStrictResult } from '../../config';
|
||||
import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
||||
import {
|
||||
classifyConfigHotReloadDiff,
|
||||
type ConfigHotReloadDiff,
|
||||
} from '../../core/services/config-hot-reload';
|
||||
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
|
||||
createSaveConfigSettingsPatchHandler,
|
||||
type ConfigSettingsHotReloadDiff,
|
||||
} from './config-settings-save';
|
||||
import {
|
||||
createOpenConfigSettingsWindowHandler,
|
||||
type ConfigSettingsWindowLike,
|
||||
@@ -28,6 +29,19 @@ export interface ConfigSettingsIpcChannels {
|
||||
saveConfigSettingsPatch: string;
|
||||
openConfigSettingsFile: string;
|
||||
openConfigSettingsWindow: string;
|
||||
getConfigSettingsAnkiDeckNames: string;
|
||||
getConfigSettingsAnkiDeckFieldNames: string;
|
||||
getConfigSettingsAnkiDeckModelNames: string;
|
||||
getConfigSettingsAnkiModelNames: string;
|
||||
getConfigSettingsAnkiModelFieldNames: string;
|
||||
}
|
||||
|
||||
export interface ConfigSettingsAnkiClient {
|
||||
deckNames(): Promise<string[]>;
|
||||
fieldNamesForDeck(deckName: string): Promise<string[]>;
|
||||
modelNamesForDeck(deckName: string): Promise<string[]>;
|
||||
modelNames(): Promise<string[]>;
|
||||
modelFieldNames(modelName: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
|
||||
@@ -37,12 +51,14 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
|
||||
getConfig(): ResolvedConfig;
|
||||
getWarnings(): ConfigValidationWarning[];
|
||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||
applyHotReload(diff: ConfigHotReloadDiff, config: ResolvedConfig): void;
|
||||
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
|
||||
getSettingsWindow(): TWindow | null;
|
||||
setSettingsWindow(window: TWindow | null): void;
|
||||
createSettingsWindow(): TWindow;
|
||||
settingsHtmlPath: string;
|
||||
openPath(path: string): Promise<string>;
|
||||
defaultAnkiConnectUrl: string;
|
||||
createAnkiClient(url: string): ConfigSettingsAnkiClient;
|
||||
ipcMain: ConfigSettingsIpcMainLike;
|
||||
ipcChannels: ConfigSettingsIpcChannels;
|
||||
log?: (message: string) => void;
|
||||
@@ -111,8 +127,8 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
|
||||
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
||||
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
|
||||
applyHotReload: (diff, config) => deps.applyHotReload(diff, config),
|
||||
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
|
||||
onHotReloadApplied: deps.onHotReloadApplied,
|
||||
});
|
||||
|
||||
function ensureConfigFileExists(): string {
|
||||
@@ -142,6 +158,36 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
};
|
||||
}
|
||||
|
||||
function getAnkiConnectUrl(draftUrl: unknown): string {
|
||||
return typeof draftUrl === 'string' && draftUrl.trim().length > 0
|
||||
? draftUrl.trim()
|
||||
: deps.getConfig().ankiConnect.url || deps.defaultAnkiConnectUrl;
|
||||
}
|
||||
|
||||
async function getAnkiList(
|
||||
draftUrl: unknown,
|
||||
lookup: (client: ConfigSettingsAnkiClient) => Promise<string[]>,
|
||||
): Promise<ConfigSettingsAnkiListResult> {
|
||||
try {
|
||||
const client = deps.createAnkiClient(getAnkiConnectUrl(draftUrl));
|
||||
return { ok: true, values: await lookup(client) };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
values: [],
|
||||
error: error instanceof Error ? error.message : 'Failed to query AnkiConnect.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function invalidAnkiListResult(error: string): ConfigSettingsAnkiListResult {
|
||||
return {
|
||||
ok: false,
|
||||
values: [],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function registerHandlers(): void {
|
||||
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
|
||||
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
|
||||
@@ -155,6 +201,39 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
return openError.length === 0;
|
||||
});
|
||||
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow());
|
||||
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiDeckNames, (_event, draftUrl) =>
|
||||
getAnkiList(draftUrl, (client) => client.deckNames()),
|
||||
);
|
||||
deps.ipcMain.handle(
|
||||
deps.ipcChannels.getConfigSettingsAnkiDeckFieldNames,
|
||||
(_event, deckName, draftUrl) => {
|
||||
const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : '';
|
||||
return normalizedDeckName
|
||||
? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(normalizedDeckName))
|
||||
: invalidAnkiListResult('Deck name is required.');
|
||||
},
|
||||
);
|
||||
deps.ipcMain.handle(
|
||||
deps.ipcChannels.getConfigSettingsAnkiDeckModelNames,
|
||||
(_event, deckName, draftUrl) => {
|
||||
const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : '';
|
||||
return normalizedDeckName
|
||||
? getAnkiList(draftUrl, (client) => client.modelNamesForDeck(normalizedDeckName))
|
||||
: invalidAnkiListResult('Deck name is required.');
|
||||
},
|
||||
);
|
||||
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiModelNames, (_event, draftUrl) =>
|
||||
getAnkiList(draftUrl, (client) => client.modelNames()),
|
||||
);
|
||||
deps.ipcMain.handle(
|
||||
deps.ipcChannels.getConfigSettingsAnkiModelFieldNames,
|
||||
(_event, modelName, draftUrl) => {
|
||||
const normalizedModelName = typeof modelName === 'string' ? modelName.trim() : '';
|
||||
return normalizedModelName
|
||||
? getAnkiList(draftUrl, (client) => client.modelFieldNames(normalizedModelName))
|
||||
: invalidAnkiListResult('Note type is required.');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,7 +14,7 @@ function snapshot(): ConfigSettingsSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
test('config settings save applies hot-reloadable diff live', () => {
|
||||
test('config settings save returns hot-reloadable diff for watcher path', () => {
|
||||
const calls: string[] = [];
|
||||
const previous = DEFAULT_CONFIG;
|
||||
const next: ResolvedConfig = {
|
||||
@@ -46,7 +46,6 @@ test('config settings save applies hot-reloadable diff live', () => {
|
||||
hotReloadFields: ['subtitleStyle'],
|
||||
restartRequiredFields: [],
|
||||
}),
|
||||
applyHotReload: (diff) => calls.push(`hot:${diff.hotReloadFields.join(',')}`),
|
||||
getRestartRequiredSections: () => [],
|
||||
});
|
||||
|
||||
@@ -62,11 +61,81 @@ test('config settings save applies hot-reloadable diff live', () => {
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.match(written, /autoPauseVideoOnHover/);
|
||||
assert.deepEqual(calls, ['write', 'hot:subtitleStyle']);
|
||||
assert.deepEqual(calls, ['write']);
|
||||
assert.deepEqual(result.hotReloadFields, ['subtitleStyle']);
|
||||
assert.deepEqual(result.restartRequiredFields, []);
|
||||
});
|
||||
|
||||
test('config settings save immediately applies hot-reloadable subtitle CSS changes', () => {
|
||||
const previous = DEFAULT_CONFIG;
|
||||
const next: ResolvedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
subtitleStyle: {
|
||||
...DEFAULT_CONFIG.subtitleStyle,
|
||||
css: {
|
||||
'font-size': '50px',
|
||||
},
|
||||
secondary: {
|
||||
...DEFAULT_CONFIG.subtitleStyle.secondary,
|
||||
css: {
|
||||
'font-size': '28px',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const applied: Array<{
|
||||
hotReloadFields: string[];
|
||||
config: ResolvedConfig;
|
||||
}> = [];
|
||||
const save = createSaveConfigSettingsPatchHandler({
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
getCurrentConfig: () => previous,
|
||||
getWarnings: () => [],
|
||||
getSnapshot: () => snapshot(),
|
||||
fileExists: () => true,
|
||||
readText: () => '{}',
|
||||
writeTextAtomically: () => {},
|
||||
reloadConfigStrict: (): ReloadConfigStrictResult => ({
|
||||
ok: true,
|
||||
config: next,
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
classifyDiff: () => ({
|
||||
hotReloadFields: ['subtitleStyle'],
|
||||
restartRequiredFields: [],
|
||||
}),
|
||||
getRestartRequiredSections: () => [],
|
||||
onHotReloadApplied: (diff, config) => {
|
||||
applied.push({
|
||||
hotReloadFields: diff.hotReloadFields,
|
||||
config,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = save({
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.css',
|
||||
value: { 'font-size': '50px' },
|
||||
},
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.secondary.css',
|
||||
value: { 'font-size': '28px' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(applied.length, 1);
|
||||
assert.deepEqual(applied[0]?.hotReloadFields, ['subtitleStyle']);
|
||||
assert.equal(applied[0]?.config.subtitleStyle.css['font-size'], '50px');
|
||||
assert.equal(applied[0]?.config.subtitleStyle.secondary.css['font-size'], '28px');
|
||||
});
|
||||
|
||||
test('config settings save returns restart-required sections without applying hot reload', () => {
|
||||
const calls: string[] = [];
|
||||
const previous = DEFAULT_CONFIG;
|
||||
@@ -95,7 +164,6 @@ test('config settings save returns restart-required sections without applying ho
|
||||
hotReloadFields: [],
|
||||
restartRequiredFields: ['mpv'],
|
||||
}),
|
||||
applyHotReload: () => calls.push('hot'),
|
||||
getRestartRequiredSections: () => ['mpv launcher'],
|
||||
});
|
||||
|
||||
@@ -130,9 +198,6 @@ test('config settings save restores previous file content when strict reload fai
|
||||
classifyDiff: () => {
|
||||
throw new Error('Should not classify invalid config.');
|
||||
},
|
||||
applyHotReload: () => {
|
||||
throw new Error('Should not hot reload invalid config.');
|
||||
},
|
||||
getRestartRequiredSections: () => [],
|
||||
});
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ export interface ConfigSettingsSaveDeps {
|
||||
deleteFile?(path: string): void;
|
||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
|
||||
applyHotReload(diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig): void;
|
||||
getRestartRequiredSections(restartRequiredFields: string[]): string[];
|
||||
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
|
||||
}
|
||||
|
||||
export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) {
|
||||
@@ -64,12 +64,17 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
|
||||
deps.writeTextAtomically(configPath, candidate.content);
|
||||
const reloadResult = deps.reloadConfigStrict();
|
||||
if (!reloadResult.ok) {
|
||||
if (hadExistingConfig) {
|
||||
deps.writeTextAtomically(configPath, content);
|
||||
} else if (deps.deleteFile) {
|
||||
deps.deleteFile(configPath);
|
||||
} else {
|
||||
deps.writeTextAtomically(configPath, content);
|
||||
try {
|
||||
if (hadExistingConfig) {
|
||||
deps.writeTextAtomically(configPath, content);
|
||||
} else if (deps.deleteFile) {
|
||||
deps.deleteFile(configPath);
|
||||
} else {
|
||||
deps.writeTextAtomically(configPath, content);
|
||||
}
|
||||
deps.reloadConfigStrict();
|
||||
} catch {
|
||||
// Best-effort rollback; preserve original reload error for caller.
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
@@ -83,7 +88,7 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
|
||||
|
||||
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.applyHotReload(diff, reloadResult.config);
|
||||
deps.onHotReloadApplied?.(diff, reloadResult.config);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -27,7 +27,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
|
||||
const window = deps.createSettingsWindow();
|
||||
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.log?.(`Failed to load configuration settings window: ${message}`);
|
||||
deps.log?.(`Failed to load settings window: ${message}`);
|
||||
deps.setSettingsWindow(null);
|
||||
window.destroy?.();
|
||||
});
|
||||
|
||||
@@ -41,18 +41,16 @@ test('current media tokenization gate returns immediately for ready media', asyn
|
||||
await gate.waitUntilReady('/tmp/video-1.mkv');
|
||||
});
|
||||
|
||||
test('current media tokenization gate stays ready for later media after first warmup', async () => {
|
||||
test('current media tokenization gate treats later media as ready after warmup completes', async () => {
|
||||
const gate = createCurrentMediaTokenizationGate();
|
||||
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
|
||||
gate.markReady('/tmp/video-1.mkv');
|
||||
gate.updateCurrentMediaPath('/tmp/video-2.mkv');
|
||||
|
||||
let resolved = false;
|
||||
const waitPromise = gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
|
||||
await gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(resolved, true);
|
||||
await waitPromise;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import { resolveCurrentSubtitleForRenderer } from './current-subtitle-snapshot';
|
||||
|
||||
function withTiming(payload: SubtitleData): SubtitleData {
|
||||
return {
|
||||
...payload,
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
};
|
||||
}
|
||||
|
||||
test('renderer current subtitle snapshot reuses cached payload for first paint', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: '字幕',
|
||||
currentSubtitleData: { text: '字幕', tokens: [{ text: '字' } as never] },
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
});
|
||||
|
||||
assert.equal(payload.text, '字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.deepEqual(payload.tokens, [{ text: '字' }]);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot does not block on tokenizer for empty text', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: '',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
});
|
||||
|
||||
assert.equal(payload.text, '');
|
||||
assert.equal(payload.tokens, null);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot falls back to raw text for uncached subtitles', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: 'まだキャッシュされていない字幕',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
});
|
||||
|
||||
assert.equal(payload.text, 'まだキャッシュされていない字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.equal(payload.tokens, null);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot tokenizes uncached subtitles when tokenizer is available', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: '新しい字幕',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '新' } as never] }),
|
||||
});
|
||||
|
||||
assert.equal(payload.text, '新しい字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.deepEqual(payload.tokens, [{ text: '新' }]);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
currentSubText: string;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
|
||||
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
|
||||
}): Promise<SubtitleData> {
|
||||
if (deps.currentSubtitleData?.text === deps.currentSubText) {
|
||||
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
|
||||
}
|
||||
|
||||
if (!deps.currentSubText.trim()) {
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
|
||||
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
|
||||
if (tokenized) {
|
||||
return deps.withCurrentSubtitleTiming(tokenized);
|
||||
}
|
||||
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
removeLegacyMpvPluginCandidates,
|
||||
resolvePackagedFirstRunPluginAssets,
|
||||
resolvePackagedRuntimePluginPath,
|
||||
syncInstalledFirstRunPluginBinaryPath,
|
||||
} from './first-run-setup-plugin';
|
||||
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
|
||||
|
||||
@@ -66,66 +65,6 @@ test('resolvePackagedRuntimePluginPath returns packaged plugin entrypoint', () =
|
||||
});
|
||||
});
|
||||
|
||||
test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing installs', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
installPaths.pluginConfigPath,
|
||||
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
|
||||
const result = syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: 'linux',
|
||||
homeDir,
|
||||
xdgConfigHome,
|
||||
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
updated: true,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
});
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overrides', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
installPaths.pluginConfigPath,
|
||||
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
|
||||
const result = syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: 'linux',
|
||||
homeDir,
|
||||
xdgConfigHome,
|
||||
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
updated: false,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
});
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv config location on macOS', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
|
||||
import type { MpvInstallPaths } from '../../shared/setup-state';
|
||||
|
||||
export interface InstalledFirstRunPluginCandidate {
|
||||
path: string;
|
||||
@@ -27,51 +27,6 @@ export interface LegacyMpvPluginRemovalResult {
|
||||
failedPaths: Array<{ path: string; message: string }>;
|
||||
}
|
||||
|
||||
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const updated = content.replace(/^socket_path=.*$/m, 'socket_path=\\\\.\\pipe\\subminer-socket');
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePluginConfigValue(value: string): string {
|
||||
return value.replace(/[\r\n]/g, '').trim();
|
||||
}
|
||||
|
||||
function upsertPluginConfigLine(content: string, key: string, value: string): string {
|
||||
const normalizedValue = sanitizePluginConfigValue(value);
|
||||
const line = `${key}=${normalizedValue}`;
|
||||
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (pattern.test(content)) {
|
||||
return content.replace(pattern, line);
|
||||
}
|
||||
|
||||
const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
|
||||
return `${content}${suffix}${line}\n`;
|
||||
}
|
||||
|
||||
function rewriteInstalledPluginBinaryPath(configPath: string, binaryPath: string): boolean {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const updated = upsertPluginConfigLine(content, 'binary_path', binaryPath);
|
||||
if (updated === content) {
|
||||
return false;
|
||||
}
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
function readInstalledPluginBinaryPath(configPath: string): string | null {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const match = content.match(/^binary_path=(.*)$/m);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rawValue = match[1] ?? '';
|
||||
const value = sanitizePluginConfigValue(rawValue);
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function resolvePackagedFirstRunPluginAssets(deps: {
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
@@ -338,36 +293,3 @@ export async function removeLegacyMpvPluginCandidates(options: {
|
||||
failedPaths,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncInstalledFirstRunPluginBinaryPath(options: {
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
binaryPath: string;
|
||||
}): { updated: boolean; configPath: string | null } {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
options.platform,
|
||||
options.homeDir,
|
||||
options.xdgConfigHome,
|
||||
);
|
||||
if (!installPaths.supported || !fs.existsSync(installPaths.pluginConfigPath)) {
|
||||
return { updated: false, configPath: null };
|
||||
}
|
||||
|
||||
const configuredBinaryPath = readInstalledPluginBinaryPath(installPaths.pluginConfigPath);
|
||||
if (configuredBinaryPath) {
|
||||
return { updated: false, configPath: installPaths.pluginConfigPath };
|
||||
}
|
||||
|
||||
const updated = rewriteInstalledPluginBinaryPath(
|
||||
installPaths.pluginConfigPath,
|
||||
options.binaryPath,
|
||||
);
|
||||
if (options.platform === 'win32') {
|
||||
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
|
||||
}
|
||||
return {
|
||||
updated,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -47,6 +47,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
markWatched: false,
|
||||
toggleSubtitleSidebar: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
@@ -121,12 +122,12 @@ function createCommandLineLauncherSnapshot(
|
||||
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, configSettings: true })), false);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, settings: true })), false);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ yomitan: true })), false);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
|
||||
});
|
||||
|
||||
@@ -155,6 +156,7 @@ test('shouldAutoOpenFirstRunSetup treats session and stats startup commands as e
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openControllerDebug: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, markWatched: true })), false);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, stats: true })), false);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinSubtitleUrlsOnly: true })),
|
||||
|
||||
@@ -71,8 +71,8 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.launchMpv ||
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
@@ -90,6 +90,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.markWatched ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
|
||||
@@ -59,10 +59,15 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
assert.match(html, /SubMiner setup/);
|
||||
assert.doesNotMatch(html, /Install legacy mpv plugin/);
|
||||
assert.doesNotMatch(html, /action=install-plugin/);
|
||||
assert.match(html, /Ready/);
|
||||
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||
assert.doesNotMatch(html, /Bundled ready/);
|
||||
assert.match(html, /Managed mpv launches use the bundled runtime plugin\./);
|
||||
assert.doesNotMatch(html, /Managed mpv launches use the bundled runtime plugin\./);
|
||||
assert.match(html, /Open Yomitan Settings/);
|
||||
assert.match(html, /Open SubMiner Settings/);
|
||||
assert.match(
|
||||
html,
|
||||
/action=open-yomitan-settings'">Open Yomitan Settings<\/button>\s*<button class="ghost" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=refresh'">Refresh status<\/button>\s*<button onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=open-config-settings'">Open SubMiner Settings<\/button>\s*<button class="primary" disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
|
||||
);
|
||||
assert.match(html, /Finish setup/);
|
||||
assert.match(html, /disabled/);
|
||||
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
|
||||
@@ -70,7 +75,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
assert.match(html, /box-sizing:\s*border-box;/);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
|
||||
test('buildFirstRunSetupHtml omits bundled mpv plugin readiness when already installed', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
configReady: true,
|
||||
dictionaryCount: 1,
|
||||
@@ -94,10 +99,11 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
||||
|
||||
assert.doesNotMatch(html, /Reinstall mpv plugin/);
|
||||
assert.doesNotMatch(html, /action=install-plugin/);
|
||||
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||
assert.match(html, /mpv executable path/);
|
||||
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
|
||||
assert.match(html, /aria-label="Path to mpv\.exe"/);
|
||||
assert.match(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
|
||||
assert.doesNotMatch(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
|
||||
@@ -124,7 +130,8 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
|
||||
});
|
||||
|
||||
assert.match(html, /Legacy mpv plugin/);
|
||||
assert.match(html, /Legacy detected/);
|
||||
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||
assert.match(html, /Found/);
|
||||
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
|
||||
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
|
||||
assert.match(html, /Remove legacy mpv plugin/);
|
||||
@@ -251,6 +258,12 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||
action: 'remove-legacy-plugin',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=open-config-settings'),
|
||||
{
|
||||
action: 'open-config-settings',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
|
||||
null,
|
||||
@@ -542,6 +555,89 @@ test('opening first-run setup skips rendering if window is destroyed after snaps
|
||||
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
|
||||
});
|
||||
|
||||
test('first-run setup action can skip rerender after launching another window', async () => {
|
||||
const calls: string[] = [];
|
||||
let navigateHandler: ((event: unknown, url: string) => void) | undefined;
|
||||
const handler = createOpenFirstRunSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () =>
|
||||
({
|
||||
webContents: {
|
||||
on: (_event: 'will-navigate', callback: (event: unknown, url: string) => void) => {
|
||||
navigateHandler = callback;
|
||||
},
|
||||
},
|
||||
loadURL: async () => {
|
||||
calls.push('load');
|
||||
},
|
||||
on: () => {},
|
||||
isDestroyed: () => false,
|
||||
close: () => {},
|
||||
show: () => calls.push('show'),
|
||||
focus: () => calls.push('focus'),
|
||||
}) as never,
|
||||
getSetupSnapshot: async () => ({
|
||||
configReady: true,
|
||||
dictionaryCount: 1,
|
||||
canFinish: true,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
mpvExecutablePath: '',
|
||||
mpvExecutablePathStatus: 'blank',
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
}),
|
||||
buildSetupHtml: () => '<html></html>',
|
||||
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
|
||||
handleAction: async () => {
|
||||
calls.push('action');
|
||||
return { skipRender: true };
|
||||
},
|
||||
markSetupInProgress: async () => {
|
||||
calls.push('in-progress');
|
||||
},
|
||||
markSetupCancelled: async () => undefined,
|
||||
isSetupCompleted: () => true,
|
||||
shouldQuitWhenClosedIncomplete: () => false,
|
||||
quitApp: () => {},
|
||||
clearSetupWindow: () => {},
|
||||
setSetupWindow: () => {
|
||||
calls.push('set');
|
||||
},
|
||||
encodeURIComponent: (value) => value,
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
handler();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
navigateHandler?.(
|
||||
{ preventDefault: () => calls.push('preventDefault') },
|
||||
'subminer://first-run-setup?action=open-config-settings',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'set',
|
||||
'show',
|
||||
'focus',
|
||||
'in-progress',
|
||||
'load',
|
||||
'show',
|
||||
'focus',
|
||||
'preventDefault',
|
||||
'action',
|
||||
]);
|
||||
});
|
||||
|
||||
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
||||
const calls: string[] = [];
|
||||
let closedHandler: (() => void) | undefined;
|
||||
|
||||
@@ -29,6 +29,7 @@ export type FirstRunSetupAction =
|
||||
| 'install-bun'
|
||||
| 'install-command-line-launcher'
|
||||
| 'open-yomitan-settings'
|
||||
| 'open-config-settings'
|
||||
| 'refresh'
|
||||
| 'finish';
|
||||
|
||||
@@ -200,14 +201,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
legacyMpvPluginPaths.length > 0 && model.canFinish
|
||||
? 'Continue without removing'
|
||||
: 'Finish setup';
|
||||
const pluginLabel =
|
||||
legacyMpvPluginPaths.length > 0
|
||||
? 'Legacy detected'
|
||||
: model.pluginStatus === 'failed'
|
||||
? 'Failed'
|
||||
: 'Ready';
|
||||
const pluginTone =
|
||||
legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready';
|
||||
const windowsShortcutLabel =
|
||||
model.windowsMpvShortcuts.status === 'installed'
|
||||
? 'Installed'
|
||||
@@ -326,7 +319,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
: model.canFinish
|
||||
? model.externalYomitanConfigured
|
||||
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
||||
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary. SubMiner-managed mpv launches use the bundled runtime plugin.'
|
||||
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary.'
|
||||
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
|
||||
|
||||
return `<!doctype html>
|
||||
@@ -522,14 +515,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>
|
||||
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
|
||||
</div>
|
||||
<div class="card">
|
||||
<div>
|
||||
<strong>mpv runtime plugin</strong>
|
||||
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
|
||||
<div class="meta">Managed mpv launches use the bundled runtime plugin.</div>
|
||||
</div>
|
||||
${renderStatusBadge(pluginLabel, pluginTone)}
|
||||
</div>
|
||||
<div class="card">
|
||||
<div>
|
||||
<strong>Yomitan dictionaries</strong>
|
||||
@@ -544,6 +529,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
<div class="actions">
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
|
||||
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
|
||||
</div>
|
||||
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
||||
@@ -566,6 +552,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
||||
action !== 'install-bun' &&
|
||||
action !== 'install-command-line-launcher' &&
|
||||
action !== 'open-yomitan-settings' &&
|
||||
action !== 'open-config-settings' &&
|
||||
action !== 'refresh' &&
|
||||
action !== 'finish'
|
||||
) {
|
||||
@@ -632,7 +619,9 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
|
||||
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
|
||||
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
|
||||
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>;
|
||||
handleAction: (
|
||||
submission: FirstRunSetupSubmission,
|
||||
) => Promise<{ closeWindow?: boolean; skipRender?: boolean } | void>;
|
||||
markSetupInProgress: () => Promise<unknown>;
|
||||
markSetupCancelled: () => Promise<unknown>;
|
||||
isSetupCompleted: () => boolean;
|
||||
@@ -680,6 +669,9 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result?.skipRender) {
|
||||
return;
|
||||
}
|
||||
if (!setupWindow.isDestroyed()) {
|
||||
await render();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
getLaunchMode: () => deps.getLaunchMode(),
|
||||
platform: deps.platform,
|
||||
execPath: deps.execPath,
|
||||
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
|
||||
defaultMpvLogPath: deps.defaultMpvLogPath,
|
||||
defaultMpvArgs: deps.defaultMpvArgs,
|
||||
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
|
||||
|
||||
@@ -56,6 +56,51 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
|
||||
});
|
||||
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
|
||||
const spawnedArgs: string[][] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
execPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
getPluginRuntimeConfig: () => ({
|
||||
socketPath: '/tmp/ignored-config.sock',
|
||||
binaryPath: '/custom/SubMiner.AppImage',
|
||||
backend: 'x11',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'F8',
|
||||
}),
|
||||
defaultMpvLogPath: '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
unref: () => {},
|
||||
};
|
||||
},
|
||||
logWarn: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
launch();
|
||||
const scriptOpts = spawnedArgs[0]?.find((arg) => arg.startsWith('--script-opts='));
|
||||
assert.match(scriptOpts ?? '', /subminer-binary_path=\/custom\/SubMiner\.AppImage/);
|
||||
assert.match(scriptOpts ?? '', /subminer-socket_path=\/tmp\/subminer\.sock/);
|
||||
assert.match(scriptOpts ?? '', /subminer-backend=x11/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
});
|
||||
|
||||
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
||||
let autoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let launchCalls = 0;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
|
||||
import {
|
||||
buildSubminerPluginRuntimeScriptOptParts,
|
||||
type SubminerPluginRuntimeScriptOptConfig,
|
||||
} from '../../shared/subminer-plugin-script-opts';
|
||||
import type { MpvLaunchMode } from '../../types/config';
|
||||
|
||||
type MpvClientLike = {
|
||||
@@ -40,6 +44,7 @@ export type LaunchMpvForJellyfinDeps = {
|
||||
getLaunchMode: () => MpvLaunchMode;
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
||||
defaultMpvLogPath: string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
removeSocketPath: (socketPath: string) => void;
|
||||
@@ -59,7 +64,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
||||
}
|
||||
}
|
||||
|
||||
const scriptOpts = `--script-opts=subminer-binary_path=${deps.execPath},subminer-socket_path=${socketPath}`;
|
||||
const pluginRuntimeConfig = deps.getPluginRuntimeConfig?.();
|
||||
const scriptOptParts = pluginRuntimeConfig
|
||||
? buildSubminerPluginRuntimeScriptOptParts(
|
||||
{
|
||||
...pluginRuntimeConfig,
|
||||
socketPath,
|
||||
},
|
||||
deps.execPath,
|
||||
)
|
||||
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
||||
const mpvArgs = [
|
||||
...deps.defaultMpvArgs,
|
||||
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
|
||||
|
||||
@@ -144,3 +144,34 @@ test('managed local subtitle selection runtime promotes a single unlabeled exter
|
||||
['set_property', 'secondary-sid', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test('managed local subtitle selection keeps waiting for primary after early secondary-only track list', () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const runtime = createManagedLocalSubtitleSelectionRuntime({
|
||||
getCurrentMediaPath: () => '/videos/example.mkv',
|
||||
getMpvClient: () => null,
|
||||
getPrimarySubtitleLanguages: () => [],
|
||||
getSecondarySubtitleLanguages: () => [],
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
},
|
||||
schedule: () => 1 as never,
|
||||
clearScheduled: () => {},
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('/videos/example.mkv');
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 1, lang: 'eng', title: 'ASS', external: false },
|
||||
{ type: 'sub', id: 2, lang: 'en', title: 'en.srt', external: true },
|
||||
]);
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 1, lang: 'eng', title: 'ASS', external: false },
|
||||
{ type: 'sub', id: 2, lang: 'en', title: 'en.srt', external: true },
|
||||
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
|
||||
]);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'secondary-sid', 2],
|
||||
['set_property', 'sid', 3],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -200,7 +200,8 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
}) {
|
||||
const delayMs = deps.delayMs ?? 400;
|
||||
let currentMediaPath: string | null = null;
|
||||
let appliedMediaPath: string | null = null;
|
||||
let appliedPrimaryMediaPath: string | null = null;
|
||||
let appliedSecondaryMediaPath: string | null = null;
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
@@ -212,7 +213,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
};
|
||||
|
||||
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
||||
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
|
||||
if (
|
||||
!currentMediaPath ||
|
||||
(appliedPrimaryMediaPath === currentMediaPath &&
|
||||
appliedSecondaryMediaPath === currentMediaPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selection = resolveManagedLocalSubtitleSelection({
|
||||
@@ -223,14 +228,17 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) {
|
||||
return;
|
||||
}
|
||||
if (selection.primaryTrackId !== null) {
|
||||
if (selection.primaryTrackId !== null && appliedPrimaryMediaPath !== currentMediaPath) {
|
||||
deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]);
|
||||
appliedPrimaryMediaPath = currentMediaPath;
|
||||
}
|
||||
if (selection.secondaryTrackId !== null) {
|
||||
if (selection.secondaryTrackId !== null && appliedSecondaryMediaPath !== currentMediaPath) {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
||||
appliedSecondaryMediaPath = currentMediaPath;
|
||||
}
|
||||
if (appliedPrimaryMediaPath === currentMediaPath) {
|
||||
clearPendingTimer();
|
||||
}
|
||||
appliedMediaPath = currentMediaPath;
|
||||
clearPendingTimer();
|
||||
};
|
||||
|
||||
const refreshFromMpv = async (): Promise<void> => {
|
||||
@@ -252,7 +260,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
|
||||
const scheduleRefresh = (): void => {
|
||||
clearPendingTimer();
|
||||
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
|
||||
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
@@ -265,7 +273,8 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
|
||||
const normalizedPath = normalizeLocalMediaPath(mediaPath);
|
||||
if (normalizedPath !== currentMediaPath) {
|
||||
appliedMediaPath = null;
|
||||
appliedPrimaryMediaPath = null;
|
||||
appliedSecondaryMediaPath = null;
|
||||
}
|
||||
currentMediaPath = normalizedPath;
|
||||
if (!currentMediaPath) {
|
||||
|
||||
@@ -54,6 +54,34 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
|
||||
assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']);
|
||||
});
|
||||
|
||||
test('mpv connection handler runs connected hook on connect', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvConnectionChangeHandler({
|
||||
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
onConnected: () => calls.push('connected-hook'),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => false,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => calls.push('schedule'),
|
||||
isMpvConnected: () => false,
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
handler({ connected: true });
|
||||
handler({ connected: false });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'presence-refresh',
|
||||
'sync-overlay-mpv-sub',
|
||||
'connected-hook',
|
||||
'presence-refresh',
|
||||
'report-stop',
|
||||
]);
|
||||
});
|
||||
|
||||
test('mpv connection handler quits standalone youtube playback even after overlay runtime init', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvConnectionChangeHandler({
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
onConnected?: () => void;
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
|
||||
@@ -39,6 +40,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
deps.refreshDiscordPresence();
|
||||
if (connected) {
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
deps.onConnected?.();
|
||||
return;
|
||||
}
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
|
||||
import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
|
||||
|
||||
export function createApplyJellyfinMpvDefaultsHandler(deps: {
|
||||
sendMpvCommandRuntime: (client: MpvRuntimeClientLike, command: [string, string, string]) => void;
|
||||
@@ -17,9 +18,6 @@ export function createApplyJellyfinMpvDefaultsHandler(deps: {
|
||||
|
||||
export function createGetDefaultSocketPathHandler(deps: { platform: string }) {
|
||||
return (): string => {
|
||||
if (deps.platform === 'win32') {
|
||||
return '\\\\.\\pipe\\subminer-socket';
|
||||
}
|
||||
return '/tmp/subminer-socket';
|
||||
return getDefaultMpvSocketPath(deps.platform as NodeJS.Platform);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,7 +119,6 @@ test('media path change handler reports stop for empty path and probes media key
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
@@ -138,7 +137,7 @@ test('media path change handler reports stop for empty path and probes media key
|
||||
]);
|
||||
});
|
||||
|
||||
test('media path change handler signals autoplay-ready fast path for warm non-empty media', () => {
|
||||
test('media path change handler signals autoplay readiness from warm media path', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaPathChangeHandler({
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
|
||||
@@ -45,6 +45,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync-immersion'),
|
||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
||||
|
||||
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`),
|
||||
@@ -72,6 +73,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
handlers.get('subtitle-change')?.({ text: 'line' });
|
||||
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
||||
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||
handlers.get('media-path-change')?.({ path: '/tmp/video.mkv' });
|
||||
handlers.get('media-path-change')?.({ path: '' });
|
||||
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
||||
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
|
||||
@@ -85,7 +87,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
assert.ok(calls.includes('subtitle-track-change'));
|
||||
assert.ok(calls.includes('subtitle-track-list-change'));
|
||||
assert.ok(calls.includes('media-title:Episode 1'));
|
||||
assert.ok(calls.includes('restore-mpv-sub'));
|
||||
assert.ok(calls.includes('media-path:/tmp/video.mkv'));
|
||||
assert.ok(calls.includes('autoplay:/tmp/video.mkv'));
|
||||
assert.ok(calls.includes('reset-guess-state'));
|
||||
assert.ok(calls.includes('notify-title:Episode 1'));
|
||||
assert.ok(calls.includes('post-watch:901'));
|
||||
@@ -95,3 +98,130 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('flush-playback'));
|
||||
});
|
||||
|
||||
test('main mpv event binder runs mpv-connected callback on connection', () => {
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
const calls: string[] = [];
|
||||
|
||||
const bind = createBindMpvMainEventHandlersHandler({
|
||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
onMpvConnected: () => calls.push('mpv-connected'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => false,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
isMpvConnected: () => true,
|
||||
quitApp: () => {},
|
||||
|
||||
recordImmersionSubtitleLine: () => {},
|
||||
hasSubtitleTimingTracker: () => false,
|
||||
recordSubtitleTiming: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
setCurrentSubText: () => {},
|
||||
broadcastSubtitle: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
|
||||
setCurrentSubAssText: () => {},
|
||||
broadcastSubtitleAss: () => {},
|
||||
broadcastSecondarySubtitle: () => {},
|
||||
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
notifyImmersionTitleUpdate: () => {},
|
||||
|
||||
recordPlaybackPosition: () => {},
|
||||
recordMediaDuration: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
recordPauseState: () => {},
|
||||
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
setPreviousSecondarySubVisibility: () => {},
|
||||
});
|
||||
|
||||
bind({
|
||||
on: (event, handler) => {
|
||||
handlers.set(event, handler as (payload: unknown) => void);
|
||||
},
|
||||
});
|
||||
|
||||
handlers.get('connection-change')?.({ connected: true });
|
||||
|
||||
assert.ok(calls.includes('mpv-connected'));
|
||||
});
|
||||
|
||||
test('main mpv event binder clears media path on disconnect', () => {
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
const calls: string[] = [];
|
||||
|
||||
const bind = createBindMpvMainEventHandlersHandler({
|
||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => false,
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
isMpvConnected: () => false,
|
||||
quitApp: () => {},
|
||||
|
||||
recordImmersionSubtitleLine: () => {},
|
||||
hasSubtitleTimingTracker: () => false,
|
||||
recordSubtitleTiming: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
setCurrentSubText: () => {},
|
||||
broadcastSubtitle: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
|
||||
setCurrentSubAssText: () => {},
|
||||
broadcastSubtitleAss: () => {},
|
||||
broadcastSecondarySubtitle: () => {},
|
||||
|
||||
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
notifyImmersionTitleUpdate: () => {},
|
||||
|
||||
recordPlaybackPosition: () => {},
|
||||
recordMediaDuration: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
recordPauseState: () => {},
|
||||
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
setPreviousSecondarySubVisibility: () => {},
|
||||
});
|
||||
|
||||
bind({
|
||||
on: (event, handler) => {
|
||||
handlers.set(event, handler as (payload: unknown) => void);
|
||||
},
|
||||
});
|
||||
|
||||
handlers.get('connection-change')?.({ connected: false });
|
||||
|
||||
assert.ok(calls.includes('media-path:'));
|
||||
assert.ok(calls.includes('remote-stopped'));
|
||||
assert.ok(calls.includes('presence-refresh'));
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ type AnilistPostWatchRunOptions = {
|
||||
export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
onMpvConnected?: () => void;
|
||||
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
|
||||
@@ -83,6 +84,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
onConnected: () => deps.onMpvConnected?.(),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => deps.hasInitialPlaybackQuitOnDisconnectArg(),
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
|
||||
@@ -99,6 +101,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
}): void => {
|
||||
if (connected) {
|
||||
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||
} else {
|
||||
deps.updateCurrentMediaPath('');
|
||||
}
|
||||
handleMpvConnectionChange({ connected });
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
deps.maybeProbeAnilistDuration('media-key');
|
||||
deps.ensureAnilistMediaGuess('media-key');
|
||||
deps.syncImmersionMediaState();
|
||||
deps.signalAutoplayReadyIfWarm('/tmp/video');
|
||||
deps.signalAutoplayReadyIfWarm?.('/tmp/video');
|
||||
deps.updateCurrentMediaTitle('title');
|
||||
deps.resetAnilistMediaGuessState();
|
||||
deps.notifyImmersionTitleUpdate('title');
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
quitApp: () => void;
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
onMpvConnected?: () => void;
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||
recordAnilistMediaDuration?: (durationSec: number) => void;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
@@ -93,6 +94,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
return () => ({
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
onMpvConnected: deps.onMpvConnected ? () => deps.onMpvConnected!() : undefined,
|
||||
hasInitialPlaybackQuitOnDisconnectArg,
|
||||
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: hasInitialPlaybackQuitOnDisconnectArg,
|
||||
|
||||
@@ -104,6 +104,36 @@ test('restore keeps mpv subtitles hidden when visible-overlay binding still requ
|
||||
assert.deepEqual(calls, [false]);
|
||||
});
|
||||
|
||||
test('forced restore ignores visible-overlay suppression during app shutdown', () => {
|
||||
const state: VisibilityState = {
|
||||
savedSubVisibility: true,
|
||||
revision: 9,
|
||||
};
|
||||
const calls: boolean[] = [];
|
||||
|
||||
const restore = createRestoreOverlayMpvSubtitlesHandler({
|
||||
getSavedSubVisibility: () => state.savedSubVisibility,
|
||||
setSavedSubVisibility: (visible) => {
|
||||
state.savedSubVisibility = visible;
|
||||
},
|
||||
getRevision: () => state.revision,
|
||||
setRevision: (revision) => {
|
||||
state.revision = revision;
|
||||
},
|
||||
isMpvConnected: () => true,
|
||||
shouldKeepSuppressedFromVisibleOverlayBinding: () => true,
|
||||
setMpvSubVisibility: (visible) => {
|
||||
calls.push(visible);
|
||||
},
|
||||
});
|
||||
|
||||
restore({ force: true });
|
||||
|
||||
assert.equal(state.savedSubVisibility, null);
|
||||
assert.equal(state.revision, 10);
|
||||
assert.deepEqual(calls, [true]);
|
||||
});
|
||||
|
||||
test('restore defers mpv subtitle restore while mpv is disconnected', () => {
|
||||
const state: VisibilityState = {
|
||||
savedSubVisibility: true,
|
||||
|
||||
@@ -3,6 +3,10 @@ type MpvVisibilityClient = {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type RestoreOverlayMpvSubtitlesOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
function parseSubVisibility(value: unknown): boolean {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
@@ -81,11 +85,11 @@ export function createRestoreOverlayMpvSubtitlesHandler(deps: {
|
||||
shouldKeepSuppressedFromVisibleOverlayBinding: () => boolean;
|
||||
setMpvSubVisibility: (visible: boolean) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
return (options: RestoreOverlayMpvSubtitlesOptions = {}): void => {
|
||||
deps.setRevision(deps.getRevision() + 1);
|
||||
|
||||
const savedVisibility = deps.getSavedSubVisibility();
|
||||
if (deps.shouldKeepSuppressedFromVisibleOverlayBinding()) {
|
||||
if (!options.force && deps.shouldKeepSuppressedFromVisibleOverlayBinding()) {
|
||||
deps.setMpvSubVisibility(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind
|
||||
assert.deepEqual(options, {
|
||||
width: 1040,
|
||||
height: 760,
|
||||
title: 'SubMiner Configuration',
|
||||
title: 'SubMiner Settings',
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
resizable: true,
|
||||
@@ -118,7 +118,6 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
preload: '/tmp/preload-settings.js',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -76,10 +76,9 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
|
||||
return createSetupWindowHandler(deps, {
|
||||
width: 1040,
|
||||
height: 760,
|
||||
title: 'SubMiner Configuration',
|
||||
title: 'SubMiner Settings',
|
||||
resizable: true,
|
||||
preloadPath: deps.preloadPath,
|
||||
sandbox: false,
|
||||
backgroundColor: '#24273a',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createBuildAppLifecycleRuntimeRunnerMainDepsHandler(
|
||||
handleCliCommand: deps.handleCliCommand,
|
||||
printHelp: deps.printHelp,
|
||||
logNoRunningInstance: deps.logNoRunningInstance,
|
||||
startControlServer: deps.startControlServer,
|
||||
onReady: deps.onReady,
|
||||
onWillQuitCleanup: deps.onWillQuitCleanup,
|
||||
shouldRestoreWindowsOnActivate: deps.shouldRestoreWindowsOnActivate,
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
shouldStartAutomaticUpdateChecks,
|
||||
} from './startup-mode-flags';
|
||||
|
||||
test('config settings startup uses minimal startup and skips background integrations', () => {
|
||||
const args = parseArgs(['--config']);
|
||||
test('settings window startup uses minimal startup and skips background integrations', () => {
|
||||
const args = parseArgs(['--settings']);
|
||||
const flags = getStartupModeFlags(args);
|
||||
|
||||
assert.equal(flags.shouldUseMinimalStartup, true);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CliArgs } from '../../cli/args';
|
||||
import {
|
||||
isHeadlessInitialCommand,
|
||||
isStandaloneTexthookerCommand,
|
||||
shouldRunSettingsOnlyStartup,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
} from '../../cli/args';
|
||||
|
||||
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
@@ -12,15 +12,15 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
return {
|
||||
shouldUseMinimalStartup: Boolean(
|
||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||
initialArgs?.configSettings ||
|
||||
initialArgs?.settings ||
|
||||
initialArgs?.update ||
|
||||
(initialArgs?.stats &&
|
||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||
),
|
||||
shouldSkipHeavyStartup: Boolean(
|
||||
initialArgs &&
|
||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.configSettings ||
|
||||
(shouldRunYomitanOnlyStartup(initialArgs) ||
|
||||
initialArgs.settings ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
initialArgs.update ||
|
||||
@@ -32,9 +32,9 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
export function shouldRefreshAnilistOnConfigReload(
|
||||
initialArgs: CliArgs | null | undefined,
|
||||
): boolean {
|
||||
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
|
||||
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.settings));
|
||||
}
|
||||
|
||||
export function shouldStartAutomaticUpdateChecks(initialArgs: CliArgs | null | undefined): boolean {
|
||||
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
|
||||
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.settings));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
shouldEnsureTrayOnStartupForInitialArgs,
|
||||
shouldQuitOnMpvShutdownForTrayState,
|
||||
shouldQuitOnWindowAllClosedForTrayState,
|
||||
} from './startup-tray-policy';
|
||||
|
||||
@@ -42,3 +43,36 @@ test('window-all-closed keeps background app alive without tray', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('mpv shutdown quits managed background playback despite tray residency', () => {
|
||||
assert.equal(
|
||||
shouldQuitOnMpvShutdownForTrayState({
|
||||
managedPlayback: true,
|
||||
backgroundMode: true,
|
||||
hasTray: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('mpv shutdown quits standalone managed playback without tray residency', () => {
|
||||
assert.equal(
|
||||
shouldQuitOnMpvShutdownForTrayState({
|
||||
managedPlayback: true,
|
||||
backgroundMode: false,
|
||||
hasTray: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('mpv shutdown keeps unmanaged background tray app alive', () => {
|
||||
assert.equal(
|
||||
shouldQuitOnMpvShutdownForTrayState({
|
||||
managedPlayback: false,
|
||||
backgroundMode: true,
|
||||
hasTray: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,3 +21,12 @@ export function shouldQuitOnWindowAllClosedForTrayState(options: {
|
||||
if (options.hasTray) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldQuitOnMpvShutdownForTrayState(options: {
|
||||
managedPlayback: boolean;
|
||||
backgroundMode: boolean;
|
||||
hasTray: boolean;
|
||||
}): boolean {
|
||||
// managedPlayback marks process ownership; tray/background only affect window-close policy.
|
||||
return options.managedPlayback;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
|
||||
isKnownWord: (text) => text === 'known',
|
||||
recordLookup: (hit) => calls.push(`lookup:${hit}`),
|
||||
getKnownWordMatchMode: () => 'surface',
|
||||
getKnownWordsEnabled: () => true,
|
||||
getNPlusOneEnabled: () => true,
|
||||
getMinSentenceWordsForNPlusOne: () => 3,
|
||||
getJlptLevel: () => 'N2',
|
||||
@@ -47,6 +48,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
|
||||
deps.setYomitanParserWindow(null);
|
||||
deps.setYomitanParserReadyPromise(null);
|
||||
deps.setYomitanParserInitPromise(null);
|
||||
assert.equal(deps.getKnownWordsEnabled?.(), true);
|
||||
assert.equal(deps.getNPlusOneEnabled?.(), true);
|
||||
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
|
||||
assert.equal(deps.getNameMatchEnabled?.(), false);
|
||||
|
||||
@@ -38,6 +38,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
||||
return hit;
|
||||
},
|
||||
getKnownWordMatchMode: () => deps.getKnownWordMatchMode(),
|
||||
...(deps.getKnownWordsEnabled
|
||||
? {
|
||||
getKnownWordsEnabled: () => deps.getKnownWordsEnabled!(),
|
||||
}
|
||||
: {}),
|
||||
...(deps.getNPlusOneEnabled
|
||||
? {
|
||||
getNPlusOneEnabled: () => deps.getNPlusOneEnabled!(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
createBuildTrayMenuTemplateHandler,
|
||||
createResolveTrayIconPathHandler,
|
||||
shouldShowTexthookerTrayEntry,
|
||||
} from './tray-main-actions';
|
||||
|
||||
test('resolve tray icon path handler forwards runtime dependencies', () => {
|
||||
@@ -47,7 +48,6 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
handlers.openFirstRunSetup();
|
||||
handlers.openWindowsMpvLauncherSetup();
|
||||
handlers.openYomitanSettings();
|
||||
handlers.openRuntimeOptions();
|
||||
handlers.openConfigSettings();
|
||||
handlers.openJellyfinSetup();
|
||||
handlers.toggleJellyfinDiscovery();
|
||||
@@ -68,7 +68,6 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
@@ -91,7 +90,6 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'setup',
|
||||
'setup',
|
||||
'yomitan',
|
||||
'runtime-options',
|
||||
'configuration',
|
||||
'jellyfin',
|
||||
'jellyfin-discovery',
|
||||
@@ -100,3 +98,34 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'quit',
|
||||
]);
|
||||
});
|
||||
|
||||
test('texthooker tray visibility follows websocket server enabled state', () => {
|
||||
assert.equal(
|
||||
shouldShowTexthookerTrayEntry({
|
||||
websocket: { enabled: false },
|
||||
annotationWebsocket: { enabled: false },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowTexthookerTrayEntry({
|
||||
websocket: { enabled: true },
|
||||
annotationWebsocket: { enabled: false },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowTexthookerTrayEntry({
|
||||
websocket: { enabled: 'auto' },
|
||||
annotationWebsocket: { enabled: false },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowTexthookerTrayEntry({
|
||||
websocket: { enabled: false },
|
||||
annotationWebsocket: { enabled: true },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,15 @@ export function createResolveTrayIconPathHandler(deps: {
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldShowTexthookerTrayEntry(config: {
|
||||
websocket?: { enabled?: boolean | 'auto' };
|
||||
annotationWebsocket?: { enabled?: boolean };
|
||||
}): boolean {
|
||||
const websocketEnabled = config.websocket?.enabled ?? false;
|
||||
const annotationWebsocketEnabled = config.annotationWebsocket?.enabled ?? false;
|
||||
return websocketEnabled !== false || annotationWebsocketEnabled !== false;
|
||||
}
|
||||
|
||||
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openSessionHelp: () => void;
|
||||
@@ -36,7 +45,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -55,7 +63,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
@@ -88,12 +95,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => {
|
||||
deps.openYomitanSettings();
|
||||
},
|
||||
openRuntimeOptions: () => {
|
||||
if (!deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
deps.openRuntimeOptionsPalette();
|
||||
},
|
||||
openConfigSettings: () => {
|
||||
deps.openConfigSettingsWindow();
|
||||
},
|
||||
|
||||
@@ -31,7 +31,6 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
@@ -53,7 +52,6 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openRuntimeOptions: () => calls.push('open-runtime-options'),
|
||||
openConfigSettings: () => calls.push('open-configuration'),
|
||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
|
||||
@@ -35,7 +35,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -54,7 +53,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
@@ -75,7 +73,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||
isJellyfinConfigured: deps.isJellyfinConfigured,
|
||||
|
||||
@@ -31,7 +31,6 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
isJellyfinConfigured: () => false,
|
||||
|
||||
@@ -37,7 +37,6 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptions: () => calls.push('runtime'),
|
||||
openConfigSettings: () => calls.push('configuration'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
@@ -48,7 +47,11 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.equal(template.length, 13);
|
||||
assert.equal(template.length, 12);
|
||||
assert.equal(
|
||||
template.some((entry) => entry.label === 'Open Runtime Options'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
template.some((entry) => entry.label === 'Open Overlay'),
|
||||
false,
|
||||
@@ -61,10 +64,11 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
template[0]!.click?.();
|
||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||
template[1]!.click?.();
|
||||
assert.equal(template[10]!.label, 'Check for Updates');
|
||||
template[10]!.click?.();
|
||||
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[12]!.click?.();
|
||||
assert.equal(template[5]!.label, 'Open SubMiner Settings');
|
||||
assert.equal(template[9]!.label, 'Check for Updates');
|
||||
template[9]!.click?.();
|
||||
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[11]!.click?.();
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery',
|
||||
'help',
|
||||
@@ -85,7 +89,6 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
@@ -113,7 +116,6 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
@@ -139,7 +141,6 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: true,
|
||||
|
||||
@@ -38,7 +38,6 @@ export type TrayMenuActionHandlers = {
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -90,11 +89,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
click: handlers.openYomitanSettings,
|
||||
},
|
||||
{
|
||||
label: 'Open Runtime Options',
|
||||
click: handlers.openRuntimeOptions,
|
||||
},
|
||||
{
|
||||
label: 'Open Configuration',
|
||||
label: 'Open SubMiner Settings',
|
||||
click: handlers.openConfigSettings,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -258,7 +258,7 @@ test('mac native updater supports Developer ID signed packaged app bundles', asy
|
||||
assert.deepEqual(logged, []);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
|
||||
test('linux native updater is supported for direct AppImage installs', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
@@ -270,10 +270,8 @@ test('linux native updater is unsupported even for writable direct AppImage inst
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
assert.equal(supported, true);
|
||||
assert.deepEqual(logged, []);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported when APPIMAGE is missing', async () => {
|
||||
@@ -288,25 +286,7 @@ test('linux native updater is unsupported when APPIMAGE is missing', async () =>
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported for non-writable AppImage installs', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
|
||||
},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
'Skipping native Linux updater because APPIMAGE is not set (not launched from an AppImage).',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -324,7 +304,7 @@ test('linux native updater is unsupported for package-managed AppImage installs'
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
'Skipping native Linux updater because the AppImage is managed by a system package.',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -108,15 +108,25 @@ export async function isNativeUpdaterSupported(options: {
|
||||
options.log?.('Skipping native updater because this build is not packaged.');
|
||||
return false;
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
options.log?.(
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (options.platform === 'win32') {
|
||||
return true;
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
const appImagePath = options.env?.APPIMAGE;
|
||||
if (!appImagePath) {
|
||||
options.log?.(
|
||||
'Skipping native Linux updater because APPIMAGE is not set (not launched from an AppImage).',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (isKnownLinuxPackageManagedAppImage(appImagePath)) {
|
||||
options.log?.(
|
||||
'Skipping native Linux updater because the AppImage is managed by a system package.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (options.platform !== 'darwin') {
|
||||
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElectronNetFetch, createGlobalFetch } from './fetch-adapter';
|
||||
import { createCurlFetch, createElectronNetFetch, createGlobalFetch } from './fetch-adapter';
|
||||
import type { FetchResponseLike } from './release-assets';
|
||||
|
||||
test('createElectronNetFetch delegates updater requests to Electron net.fetch', async () => {
|
||||
@@ -62,3 +62,53 @@ test('createGlobalFetch delegates updater requests to main-process fetch', async
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('createCurlFetch requests updater metadata without Electron networking', async () => {
|
||||
const calls: Array<{
|
||||
file: string;
|
||||
args: readonly string[];
|
||||
options: { encoding: 'utf8' | 'buffer'; maxBuffer?: number; timeout?: number };
|
||||
}> = [];
|
||||
const payload = Buffer.from(JSON.stringify([{ tag_name: 'v1.2.3', assets: [] }]));
|
||||
|
||||
const fetch = createCurlFetch({
|
||||
curlPath: '/usr/bin/curl',
|
||||
execFile: (file, args, options, callback) => {
|
||||
calls.push({ file, args, options });
|
||||
callback(null, payload, Buffer.alloc(0));
|
||||
return { kill: () => undefined };
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch('https://api.github.com/repos/ksyasuda/SubMiner/releases', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'SubMiner updater',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.ok, true);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), [{ tag_name: 'v1.2.3', assets: [] }]);
|
||||
assert.equal(await response.text(), '[{"tag_name":"v1.2.3","assets":[]}]');
|
||||
assert.deepEqual(Buffer.from(await response.arrayBuffer()), payload);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]?.file, '/usr/bin/curl');
|
||||
assert.deepEqual(calls[0]?.args, [
|
||||
'--fail',
|
||||
'--location',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'60',
|
||||
'--header',
|
||||
'Accept: application/vnd.github+json',
|
||||
'--header',
|
||||
'User-Agent: SubMiner updater',
|
||||
'https://api.github.com/repos/ksyasuda/SubMiner/releases',
|
||||
]);
|
||||
assert.equal(calls[0]?.options.encoding, 'buffer');
|
||||
assert.equal(calls[0]?.options.timeout, 65_000);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { execFile as defaultExecFile } from 'node:child_process';
|
||||
import type { FetchLike, FetchResponseLike } from './release-assets';
|
||||
import type { CurlExecFile } from './curl-http-executor';
|
||||
|
||||
export interface ElectronNetFetchLike {
|
||||
fetch: (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
|
||||
@@ -20,3 +22,91 @@ function getGlobalFetch(): GlobalFetchLike {
|
||||
export function createGlobalFetch(fetchImpl?: GlobalFetchLike): FetchLike {
|
||||
return (url, init) => (fetchImpl ?? getGlobalFetch())(url, init as RequestInit);
|
||||
}
|
||||
|
||||
type CurlFetchOptions = {
|
||||
execFile?: CurlExecFile;
|
||||
curlPath?: string;
|
||||
};
|
||||
|
||||
function addHeaderArgs(args: string[], headers: unknown): void {
|
||||
if (!headers) return;
|
||||
if (Array.isArray(headers)) {
|
||||
for (const header of headers) {
|
||||
if (Array.isArray(header) && header.length >= 2) {
|
||||
args.push('--header', `${header[0]}: ${header[1]}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof headers === 'object' && 'forEach' in headers) {
|
||||
(headers as { forEach: (callback: (value: string, name: string) => void) => void }).forEach(
|
||||
(value, name) => {
|
||||
args.push('--header', `${name}: ${value}`);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (typeof headers !== 'object') return;
|
||||
for (const [name, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
if (value === undefined) continue;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const item of values) {
|
||||
args.push('--header', `${name}: ${String(item)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
const result = new ArrayBuffer(buffer.length);
|
||||
new Uint8Array(result).set(buffer);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
|
||||
const execFile = options.execFile ?? (defaultExecFile as unknown as CurlExecFile);
|
||||
const curlPath = options.curlPath ?? '/usr/bin/curl';
|
||||
|
||||
return async (url, init = {}) => {
|
||||
const args = [
|
||||
'--fail',
|
||||
'--location',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'60',
|
||||
];
|
||||
addHeaderArgs(args, init.headers);
|
||||
args.push(url);
|
||||
const body = await new Promise<Buffer>((resolve, reject) => {
|
||||
execFile(
|
||||
curlPath,
|
||||
args,
|
||||
{
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 600 * 1024 * 1024,
|
||||
timeout: 65_000,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
|
||||
const errno = (error as NodeJS.ErrnoException).code;
|
||||
const fallback = errno ? `curl failed (${errno})` : 'curl failed';
|
||||
reject(new Error(stderrMessage.trim() || fallback));
|
||||
return;
|
||||
}
|
||||
resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
|
||||
},
|
||||
);
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => JSON.parse(body.toString('utf8')),
|
||||
text: async () => body.toString('utf8'),
|
||||
arrayBuffer: async () => bufferToArrayBuffer(body),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type ShowMessageBox,
|
||||
} from './update-dialogs';
|
||||
|
||||
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
|
||||
test('update dialog presenter focuses app and yields the run loop before showing macOS dialogs', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
@@ -14,7 +14,80 @@ test('update dialog presenter focuses app before showing macOS dialogs', async (
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'darwin',
|
||||
focusApp: () => calls.push('focus'),
|
||||
focusApp: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
yieldToRunLoop: async () => {
|
||||
calls.push('yield');
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'darwin',
|
||||
focusApp: async () => {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
calls.push('focus');
|
||||
},
|
||||
yieldToRunLoop: async () => {
|
||||
calls.push('yield');
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter does not focus app or yield before showing non-macOS dialogs', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'linux',
|
||||
focusApp: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
yieldToRunLoop: async () => {
|
||||
calls.push('yield');
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter still shows macOS dialog when focus fails', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'darwin',
|
||||
focusApp: () => {
|
||||
calls.push('focus');
|
||||
throw new Error('focus failed');
|
||||
},
|
||||
yieldToRunLoop: async () => {
|
||||
calls.push('yield');
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
@@ -23,21 +96,27 @@ test('update dialog presenter focuses app before showing macOS dialogs', async (
|
||||
assert.deepEqual(calls, ['focus', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter does not focus app before showing non-macOS dialogs', async () => {
|
||||
test('update dialog presenter still shows macOS dialog when yielding fails', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'linux',
|
||||
focusApp: () => calls.push('focus'),
|
||||
platform: 'darwin',
|
||||
focusApp: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
yieldToRunLoop: async () => {
|
||||
calls.push('yield');
|
||||
throw new Error('yield failed');
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
|
||||
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('manual update required dialog explains that automatic install is unavailable', async () => {
|
||||
|
||||
@@ -17,7 +17,8 @@ export type ShowMessageBox = (options: {
|
||||
|
||||
export interface UpdateDialogPresenterDeps {
|
||||
showMessageBox: ShowMessageBox;
|
||||
focusApp?: () => void;
|
||||
focusApp?: () => void | Promise<void>;
|
||||
yieldToRunLoop?: () => Promise<void>;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
@@ -33,14 +34,23 @@ export async function showNoUpdateDialog(
|
||||
});
|
||||
}
|
||||
|
||||
function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): void {
|
||||
async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<void> {
|
||||
if ((deps.platform ?? process.platform) !== 'darwin') return;
|
||||
deps.focusApp?.();
|
||||
await deps.focusApp?.();
|
||||
// Yield to the macOS run loop so the activation request is processed before the
|
||||
// modal alert blocks JS execution; without this, the alert often appears behind
|
||||
// other apps when SubMiner is not the active app at dialog-show time.
|
||||
const yieldToRunLoop = deps.yieldToRunLoop ?? (() => new Promise((r) => setTimeout(r, 0)));
|
||||
await yieldToRunLoop();
|
||||
}
|
||||
|
||||
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
||||
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
||||
maybeFocusAppForDialog(deps);
|
||||
try {
|
||||
await maybeFocusAppForDialog(deps);
|
||||
} catch {
|
||||
// Best-effort focus only; never block the dialog itself.
|
||||
}
|
||||
return deps.showMessageBox(options);
|
||||
};
|
||||
|
||||
|
||||
@@ -191,6 +191,38 @@ test('buildWindowsMpvLaunchArgs includes socket script opts when plugin entrypoi
|
||||
);
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
|
||||
const args = buildWindowsMpvLaunchArgs(
|
||||
['C:\\video.mkv'],
|
||||
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket'],
|
||||
'C:\\SubMiner\\SubMiner.exe',
|
||||
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
'normal',
|
||||
{
|
||||
socketPath: '\\\\.\\pipe\\ignored-config-socket',
|
||||
binaryPath: 'C:\\Custom\\SubMiner.exe',
|
||||
backend: 'windows',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
);
|
||||
|
||||
const scriptOpts = args.find((arg) => arg.startsWith('--script-opts='));
|
||||
assert.match(scriptOpts ?? '', /subminer-binary_path=C:\\Custom\\SubMiner\.exe/);
|
||||
assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\custom-subminer-socket/);
|
||||
assert.match(scriptOpts ?? '', /subminer-backend=windows/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports missing mpv path', async () => {
|
||||
const errors: string[] = [];
|
||||
const result = await launchWindowsMpv(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
|
||||
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts';
|
||||
import type { MpvLaunchMode } from '../../types/config';
|
||||
import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts';
|
||||
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
|
||||
|
||||
export interface WindowsMpvLaunchDeps {
|
||||
@@ -102,6 +104,7 @@ export function buildWindowsMpvLaunchArgs(
|
||||
binaryPath?: string,
|
||||
pluginEntrypointPath?: string,
|
||||
launchMode: MpvLaunchMode = 'normal',
|
||||
pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig,
|
||||
): string[] {
|
||||
const launchIdle = targets.length === 0;
|
||||
const inputIpcServer =
|
||||
@@ -112,10 +115,18 @@ export function buildWindowsMpvLaunchArgs(
|
||||
: null;
|
||||
const hasBinaryPath = typeof binaryPath === 'string' && binaryPath.trim().length > 0;
|
||||
const shouldPassSubminerScriptOpts = scriptEntrypoint || hasBinaryPath;
|
||||
const scriptOptPairs = shouldPassSubminerScriptOpts
|
||||
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
|
||||
: [];
|
||||
if (hasBinaryPath) {
|
||||
const scriptOptPairs = pluginRuntimeConfig
|
||||
? buildSubminerPluginRuntimeScriptOptParts(
|
||||
{
|
||||
...pluginRuntimeConfig,
|
||||
socketPath: inputIpcServer,
|
||||
},
|
||||
binaryPath ?? '',
|
||||
)
|
||||
: shouldPassSubminerScriptOpts
|
||||
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
|
||||
: [];
|
||||
if (!pluginRuntimeConfig && hasBinaryPath) {
|
||||
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
|
||||
}
|
||||
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
|
||||
@@ -149,6 +160,7 @@ export async function launchWindowsMpv(
|
||||
configuredMpvPath?: string,
|
||||
launchMode: MpvLaunchMode = 'normal',
|
||||
runtimePluginPolicy?: WindowsMpvRuntimePluginPolicy,
|
||||
pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig,
|
||||
): Promise<{ ok: boolean; mpvPath: string }> {
|
||||
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
|
||||
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
|
||||
@@ -192,6 +204,7 @@ export async function launchWindowsMpv(
|
||||
binaryPath,
|
||||
runtimePluginEntrypointPath,
|
||||
launchMode,
|
||||
pluginRuntimeConfig,
|
||||
),
|
||||
);
|
||||
return { ok: true, mpvPath };
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { reloadOverlayWindowsForYomitanContentScripts } from './yomitan-extension-overlay-reload';
|
||||
|
||||
test('reloadOverlayWindowsForYomitanContentScripts reloads only live overlay windows', () => {
|
||||
const calls: string[] = [];
|
||||
const windows = [
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
reload: () => calls.push('live'),
|
||||
},
|
||||
},
|
||||
{
|
||||
isDestroyed: () => true,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
reload: () => calls.push('destroyed-window'),
|
||||
},
|
||||
},
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => true,
|
||||
reload: () => calls.push('destroyed-webcontents'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(reloadOverlayWindowsForYomitanContentScripts(windows), 1);
|
||||
assert.deepEqual(calls, ['live']);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
type ReloadableWebContents = {
|
||||
isDestroyed?: () => boolean;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
type ReloadableOverlayWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
webContents?: ReloadableWebContents;
|
||||
};
|
||||
|
||||
export function reloadOverlayWindowsForYomitanContentScripts(
|
||||
windows: ReloadableOverlayWindow[],
|
||||
logWarn?: (message: string, error: unknown) => void,
|
||||
): number {
|
||||
let reloadCount = 0;
|
||||
|
||||
for (const window of windows) {
|
||||
if (window.isDestroyed()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const webContents = window.webContents;
|
||||
if (!webContents || webContents.isDestroyed?.()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
webContents.reload();
|
||||
reloadCount += 1;
|
||||
} catch (error) {
|
||||
logWarn?.('Failed to reload overlay window after Yomitan extension load.', error);
|
||||
}
|
||||
}
|
||||
|
||||
return reloadCount;
|
||||
}
|
||||
@@ -114,3 +114,83 @@ test('yomitan extension runtime direct load delegates to core', async () => {
|
||||
assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
|
||||
assert.deepEqual(yomitanSession, { id: 'session' });
|
||||
});
|
||||
|
||||
test('yomitan extension runtime notifies once after concurrent ensure load resolves', async () => {
|
||||
let extension: Extension | null = null;
|
||||
let inFlight: Promise<Extension | null> | null = null;
|
||||
const notifications: Extension[] = [];
|
||||
const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = {
|
||||
releaseLoad: null,
|
||||
};
|
||||
|
||||
const runtime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore: async (options) => {
|
||||
return await new Promise<Extension | null>((resolve) => {
|
||||
releaseLoadState.releaseLoad = (value) => {
|
||||
options.setYomitanExtension(value);
|
||||
resolve(value);
|
||||
};
|
||||
});
|
||||
},
|
||||
userDataPath: '/tmp',
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
setYomitanParserInitPromise: () => {},
|
||||
setYomitanExtension: (next) => {
|
||||
extension = next;
|
||||
},
|
||||
setYomitanSession: () => {},
|
||||
getYomitanExtension: () => extension,
|
||||
getLoadInFlight: () => inFlight,
|
||||
setLoadInFlight: (promise) => {
|
||||
inFlight = promise;
|
||||
},
|
||||
onYomitanExtensionLoaded: (loadedExtension) => {
|
||||
notifications.push(loadedExtension);
|
||||
},
|
||||
});
|
||||
|
||||
const first = runtime.ensureYomitanExtensionLoaded();
|
||||
const second = runtime.ensureYomitanExtensionLoaded();
|
||||
const fakeExtension = { id: 'yomitan' } as Extension;
|
||||
const releaseLoad = releaseLoadState.releaseLoad;
|
||||
if (!releaseLoad) {
|
||||
throw new Error('expected in-flight yomitan load resolver');
|
||||
}
|
||||
|
||||
releaseLoad(fakeExtension);
|
||||
|
||||
assert.equal(await first, fakeExtension);
|
||||
assert.equal(await second, fakeExtension);
|
||||
assert.deepEqual(notifications, [fakeExtension]);
|
||||
});
|
||||
|
||||
test('yomitan extension runtime retries notification after callback failure', async () => {
|
||||
const fakeExtension = { id: 'yomitan' } as Extension;
|
||||
let calls = 0;
|
||||
|
||||
const runtime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore: async () => fakeExtension,
|
||||
userDataPath: '/tmp',
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
setYomitanParserInitPromise: () => {},
|
||||
setYomitanExtension: () => {},
|
||||
setYomitanSession: () => {},
|
||||
getYomitanExtension: () => fakeExtension,
|
||||
getLoadInFlight: () => null,
|
||||
setLoadInFlight: () => {},
|
||||
onYomitanExtensionLoaded: () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error('overlay reload failed');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(runtime.ensureYomitanExtensionLoaded(), /overlay reload failed/);
|
||||
assert.equal(await runtime.ensureYomitanExtensionLoaded(), fakeExtension);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createEnsureYomitanExtensionLoadedHandler,
|
||||
createLoadYomitanExtensionHandler,
|
||||
} from './yomitan-extension-loader';
|
||||
import type { Extension } from 'electron';
|
||||
import {
|
||||
createBuildEnsureYomitanExtensionLoadedMainDepsHandler,
|
||||
createBuildLoadYomitanExtensionMainDepsHandler,
|
||||
@@ -17,7 +18,9 @@ type EnsureYomitanExtensionLoadedMainDeps = Omit<
|
||||
>;
|
||||
|
||||
export type YomitanExtensionRuntimeDeps = LoadYomitanExtensionMainDeps &
|
||||
EnsureYomitanExtensionLoadedMainDeps;
|
||||
EnsureYomitanExtensionLoadedMainDeps & {
|
||||
onYomitanExtensionLoaded?: (extension: Extension) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps) {
|
||||
const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({
|
||||
@@ -46,10 +49,44 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps)
|
||||
buildEnsureYomitanExtensionLoadedMainDepsHandler(),
|
||||
);
|
||||
|
||||
let lastNotifiedExtension: Extension | null = null;
|
||||
let notifyingExtension: Extension | null = null;
|
||||
let notificationPromise: Promise<void> | null = null;
|
||||
async function notifyYomitanExtensionLoaded(extension: Extension | null): Promise<void> {
|
||||
if (!extension || extension === lastNotifiedExtension) {
|
||||
return;
|
||||
}
|
||||
if (extension === notifyingExtension && notificationPromise) {
|
||||
await notificationPromise;
|
||||
return;
|
||||
}
|
||||
notifyingExtension = extension;
|
||||
notificationPromise = (async () => {
|
||||
await deps.onYomitanExtensionLoaded?.(extension);
|
||||
lastNotifiedExtension = extension;
|
||||
})();
|
||||
try {
|
||||
await notificationPromise;
|
||||
} finally {
|
||||
if (notifyingExtension === extension) {
|
||||
notifyingExtension = null;
|
||||
notificationPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadYomitanExtension: (): Promise<ReturnType<typeof deps.getYomitanExtension>> =>
|
||||
loadYomitanExtensionHandler(),
|
||||
ensureYomitanExtensionLoaded: (): Promise<ReturnType<typeof deps.getYomitanExtension>> =>
|
||||
ensureYomitanExtensionLoadedHandler(),
|
||||
loadYomitanExtension: async (): Promise<ReturnType<typeof deps.getYomitanExtension>> => {
|
||||
const extension = await loadYomitanExtensionHandler();
|
||||
await notifyYomitanExtensionLoaded(extension);
|
||||
return extension;
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async (): Promise<
|
||||
ReturnType<typeof deps.getYomitanExtension>
|
||||
> => {
|
||||
const extension = await ensureYomitanExtensionLoadedHandler();
|
||||
await notifyYomitanExtensionLoaded(extension);
|
||||
return extension;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user