feat: add subtitle generation and bundle Bun launcher runtime

- Add local subtitle generation and card timing review workflows
- Package cross-platform Bun runtimes, launchers, licenses, and source
- Consolidate release packaging and refresh v0.19.6 documentation
This commit is contained in:
2026-09-15 21:59:10 -07:00
279 changed files with 23054 additions and 1603 deletions
+10
View File
@@ -62,6 +62,10 @@ export interface MainIpcRuntimeServiceDepsParams {
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform'];
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp'];
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
@@ -79,6 +83,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getMpvInputBindings?: IpcDepsRuntimeOptions['getMpvInputBindings'];
getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
@@ -260,6 +265,10 @@ export function createMainIpcRuntimeServiceDeps(
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
onYoutubePickerResolve: params.onYoutubePickerResolve,
previewMediaTimingReview: params.previewMediaTimingReview,
getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform,
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
resolveMediaTimingReview: params.resolveMediaTimingReview,
openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp,
toggleVisibleOverlay: params.toggleVisibleOverlay,
@@ -275,6 +284,7 @@ export function createMainIpcRuntimeServiceDeps(
getMecabTokenizer: params.getMecabTokenizer,
handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings,
getMpvInputBindings: params.getMpvInputBindings,
getSessionBindings: params.getSessionBindings,
getConfiguredShortcuts: params.getConfiguredShortcuts,
dispatchSessionAction: params.dispatchSessionAction,
+62
View File
@@ -382,6 +382,8 @@ test('anime browser modal keeps its document warm across close on Linux', () =>
restoreOnModalClose: 'anime-browser',
preferModalWindow: true,
});
assert.equal(modalWindow.isVisible(), false);
runtime.notifyOverlayModalOpened('anime-browser');
assert.equal(modalWindow.isVisible(), true);
});
@@ -883,6 +885,7 @@ test('modal fallback reveal skips showing window when content is not ready', asy
setModalWindowBounds: () => {},
},
{
platform: 'darwin',
scheduleRevealFallback: (callback) => {
scheduledReveal = callback;
return { scheduled: true } as never;
@@ -1418,3 +1421,62 @@ test('modal placement reconcile cancels stale retry ladder after a newer visible
globalThis.clearTimeout = originalClearTimeout;
}
});
test('Linux keeps the dedicated modal window unmapped until the renderer opens the modal, then hides the overlay before revealing it', () => {
const mainWindow = createMockWindow();
mainWindow.visible = true;
const modalWindow = createMockWindow();
const order: string[] = [];
const hideMain = mainWindow.hide;
mainWindow.hide = () => {
order.push('main:hide');
hideMain();
};
const showModal = modalWindow.show;
modalWindow.show = () => {
order.push('modal:show');
showModal();
};
let revealScheduled = false;
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => mainWindow as never,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{
platform: 'linux',
scheduleRevealFallback: () => {
revealScheduled = true;
return { scheduled: true } as never;
},
clearRevealFallback: () => {},
},
);
const open = () =>
runtime.sendToActiveOverlayWindow(
'media-timing-review:open',
{ reviewId: 'review' },
{ restoreOnModalClose: 'media-timing-review', preferModalWindow: true },
);
assert.equal(open(), true);
assert.deepEqual(modalWindow.sent, [['media-timing-review:open', { reviewId: 'review' }]]);
assert.equal(revealScheduled, false);
assert.equal(modalWindow.getShowCount(), 0);
assert.equal(mainWindow.getHideCount(), 0);
// The open retry must not map the window before the renderer answers either.
assert.equal(open(), true);
assert.equal(modalWindow.getShowCount(), 0);
runtime.notifyOverlayModalOpened('media-timing-review');
assert.deepEqual(order, ['main:hide', 'modal:show']);
assert.equal(mainWindow.isVisible(), false);
assert.equal(modalWindow.isVisible(), true);
assert.equal(modalWindow.ignoreMouseEvents, false);
});
+21 -5
View File
@@ -93,6 +93,12 @@ export function createOverlayModalRuntimeService(
const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32';
const shouldReuseModalWindowAfterClose = (): boolean =>
platform === 'darwin' || (platform !== 'win32' && retainModalWindowState);
// On Linux (Hyprland) every placement dispatch on a mapped window (resize, move, set_prop)
// blanks the still-visible overlay for a few frames while mpv is fullscreen. Revealing the
// dedicated modal window before its renderer has the modal open runs the placement ladder,
// and the open retry, against a visible overlay, which the user sees as flicker. Keep the
// window unmapped until the renderer acknowledges the open, then hide the overlay first.
const deferModalRevealUntilOpened = platform === 'linux';
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
@@ -463,7 +469,9 @@ export function createOverlayModalRuntimeService(
deps.setModalWindowBounds(deps.getModalGeometry());
const wasVisible = modalWindow.isVisible();
if (!wasVisible) {
if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
if (deferModalRevealUntilOpened) {
// notifyOverlayModalOpened reveals the window once the renderer has the modal open.
} else if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
showModalWindow(modalWindow);
} else {
scheduleModalWindowReveal(modalWindow);
@@ -567,15 +575,23 @@ export function createOverlayModalRuntimeService(
}
const modalWindow = deps.getModalWindow();
const targetIsModalWindow =
modalWindow !== null && !modalWindow.isDestroyed() && targetWindow === modalWindow;
const handOffMainWindowToModal = (): void => {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
};
if (targetIsModalWindow && deferModalRevealUntilOpened) {
handOffMainWindowToModal();
}
if (targetWindow.isVisible()) {
ensureModalWindowInteractive(targetWindow);
} else {
showModalWindow(targetWindow);
}
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
if (targetIsModalWindow && !deferModalRevealUntilOpened) {
handOffMainWindowToModal();
}
};
@@ -48,12 +48,13 @@ test('on will quit cleanup handler runs all cleanup steps', async () => {
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
await cleanup();
assert.equal(calls.length, 35);
assert.equal(calls.length, 36);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -62,6 +63,7 @@ test('on will quit cleanup handler runs all cleanup steps', async () => {
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-remote-media-windows'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
@@ -104,6 +106,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
@@ -32,6 +32,7 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -76,6 +77,7 @@ export function createOnWillQuitCleanupHandler(deps: {
}
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.cleanupRemoteMediaWindows();
deps.stopDiscordPresenceService();
await stopSyncAutoScheduler;
};
@@ -75,6 +75,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
@@ -157,6 +158,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -210,6 +212,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -61,6 +61,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -148,6 +149,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});
+54 -12
View File
@@ -32,6 +32,8 @@ export type CommonOptions = FsDeps & {
resourcesPath?: string;
appExePath?: string;
launcherResourcePath?: string;
bundledBunPath?: string;
appVersion?: string;
runCommand?: RunCommand;
};
@@ -143,8 +145,40 @@ function needsWindowsShell(command: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
}
function quoteForWindowsShell(value: string): string {
return `"${value.replace(/([&|<>^%!])/g, '^$1').replace(/"/g, '""')}"`;
/*!
* Windows command escaping adapted from cross-spawn 7.0.6.
*
* The MIT License (MIT)
*
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes
// are consumed by cmd, leaving the quoted argument unchanged for the command.
function escapeWindowsShellArgument(value: string): string {
const quotesEscaped = value
.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
.replace(/(?=(\\+?)?)\1$/, '$1$1');
return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1');
}
function createDefaultRunCommand(): RunCommand {
@@ -153,16 +187,24 @@ function createDefaultRunCommand(): RunCommand {
const useShell = needsWindowsShell(command);
let child: ReturnType<typeof spawn>;
try {
child = useShell
? spawn(quoteForWindowsShell(command), args.map(quoteForWindowsShell), {
env: options.env ?? process.env,
windowsHide: false,
shell: true,
})
: spawn(command, args, {
env: options.env ?? process.env,
windowsHide: false,
});
const env = options.env ?? process.env;
if (useShell) {
const shellCommand = [
escapeWindowsShellArgument(command),
...args.map(escapeWindowsShellArgument),
].join(' ');
const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe';
child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], {
env,
windowsHide: false,
windowsVerbatimArguments: true,
});
} else {
child = spawn(command, args, {
env,
windowsHide: false,
});
}
} catch (error) {
resolve({
exitCode: 1,
+35 -23
View File
@@ -91,43 +91,54 @@ test('resolveBunInstallCommand prefers winget on Windows', () => {
test('default runCommand preserves Windows cmd metacharacter args', async (t) => {
if (process.platform !== 'win32') return;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cmd-args-'));
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer cmd & 100% ! '));
const scriptPath = path.join(tempDir, 'argv.cmd');
const outputPath = path.join(tempDir, 'argv.txt');
const argvScriptPath = path.join(tempDir, 'argv.js');
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
fs.writeFileSync(
argvScriptPath,
'process.stdout.write(JSON.stringify(process.argv.slice(2)));',
'utf8',
);
fs.writeFileSync(
scriptPath,
[
'@echo off',
'setlocal DisableDelayedExpansion',
'> "%SUBMINER_ARGV_OUT%" (',
' echo 1=%~1',
' echo 2=%~2',
' echo 3=%~3',
' echo 4=%~4',
' echo 5=%~5',
' echo 6=%~6',
')',
'"%SUBMINER_TEST_RUNTIME%" "%SUBMINER_ARGV_SCRIPT%" %*',
'exit /b %errorlevel%',
'',
].join('\r\n'),
'utf8',
);
const result = await getRunCommand({})(
scriptPath,
['plain', 'has space', 'a&b', 'x|y', 'p%PATH%q', 'bang!z'],
{
env: { ...process.env, SUBMINER_ARGV_OUT: outputPath },
const args = [
'plain',
'has space',
'a&b',
'x|y',
'p%TEMP%q',
'bang!z',
'caret^z',
'<left>',
'say "hi"',
'slash\\"quote',
'trailing\\',
'',
'日本語',
];
const result = await getRunCommand({})(scriptPath, args, {
env: {
...process.env,
SUBMINER_ARGV_SCRIPT: argvScriptPath,
SUBMINER_TEST_RUNTIME: process.execPath,
},
);
});
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(
fs.readFileSync(outputPath, 'utf8'),
['1=plain', '2=has space', '3=a&b', '4=x|y', '5=p%PATH%q', '6=bang!z', ''].join('\r\n'),
);
assert.deepEqual(JSON.parse(result.stdout), args);
});
test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => {
@@ -189,7 +200,7 @@ test('resolveLauncherInstallTarget prefers writable user bin on Linux', async ()
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
});
test('resolveLauncherInstallTarget returns not_installable without writable PATH dirs', async () => {
test('resolveLauncherInstallTarget offers a user bin without writable PATH dirs', async () => {
const target = await resolveLauncherInstallTarget({
platform: 'linux',
homeDir: '/home/tester',
@@ -200,8 +211,9 @@ test('resolveLauncherInstallTarget returns not_installable without writable PATH
},
});
assert.equal(target.status, 'not_installable');
assert.equal(target.installPath, null);
assert.equal(target.status, 'not_installed');
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
assert.match(target.message ?? '', /export PATH=/);
});
test('resolveLauncherInstallTarget skips Homebrew bin for empty macOS manual installs', async () => {
+205 -36
View File
@@ -1,6 +1,13 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
cleanupOldWindowsManagedRuntimes,
isManagedLauncher,
managedLauncherContent,
shellQuote,
stageManagedLauncher,
} from './managed-launcher';
import {
accessSyncOf,
envOf,
@@ -115,8 +122,13 @@ export function resolveBunInstallCommand(
}
export async function detectBun(options: CommonOptions = {}): Promise<BunSnapshot> {
const bunPath = findCommand('bun', options);
const installCommand = resolveBunInstallCommand(options);
const bundled = options.bundledBunPath;
const bunPath = bundled
? existsSyncOf(options)(bundled)
? bundled
: null
: findCommand('bun', options);
const installCommand = bundled ? null : resolveBunInstallCommand(options);
if (!bunPath) {
return {
status: 'missing',
@@ -124,7 +136,9 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
version: null,
installMethod: installMethodForCommand(installCommand),
installCommand,
message: null,
message: bundled
? 'The included launcher runtime is missing. Reinstall SubMiner to repair it.'
: null,
};
}
@@ -139,7 +153,7 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
version: result.stdout.trim() || null,
installMethod: null,
installCommand: null,
message: null,
message: bundled ? 'Included with SubMiner. No separate Bun installation is needed.' : null,
};
}
@@ -158,9 +172,11 @@ export function resolveLauncherResourcePath(options: CommonOptions): string {
if (options.launcherResourcePath) return options.launcherResourcePath;
const resourcesPath =
options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null;
const packaged = resourcesPath
? platformPath.join(resourcesPath, 'launcher', 'subminer.js')
: null;
if (packaged && existsSyncOf(options)(packaged)) return packaged;
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer');
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer.js');
}
function isWritableDir(candidate: string, options: CommonOptions): boolean {
@@ -183,6 +199,21 @@ function collectPathDirs(options: CommonOptions): string[] {
return dirs;
}
function preferredLauncherDirs(platform: NodeJS.Platform, homeDir: string): string[] {
return platform === 'darwin'
? [
'/opt/homebrew/bin',
'/usr/local/bin',
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
]
: [
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
'/usr/local/bin',
];
}
export async function resolveLauncherInstallTarget(
options: CommonOptions & WindowsPathOptions = {},
): Promise<LauncherSnapshot> {
@@ -201,19 +232,7 @@ export async function resolveLauncherInstallTarget(
const homeDir = options.homeDir ?? os.homedir();
const pathDirs = collectPathDirs(options);
const preferred =
platform === 'darwin'
? [
'/opt/homebrew/bin',
'/usr/local/bin',
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
]
: [
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
'/usr/local/bin',
];
const preferred = preferredLauncherDirs(platform, homeDir);
const manualPreferred =
platform === 'darwin'
? [
@@ -251,13 +270,15 @@ export async function resolveLauncherInstallTarget(
isWritableDir(dir, options),
);
if (!selected) {
const pathDir = path.posix.join(homeDir, '.local', 'bin');
const installPath = path.posix.join(pathDir, 'subminer');
return {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
status: existsSyncOf(options)(installPath) ? 'not_on_path' : 'not_installed',
commandPath: existsSyncOf(options)(installPath) ? installPath : null,
installPath,
pathDir,
shadowedBy: null,
message: 'No writable directory was found on your command-line PATH.',
message: `Add ${pathDir} to your terminal PATH: export PATH=${shellQuote(pathDir)}:"$PATH". Save this in your shell configuration for future terminals.`,
};
}
const installPath = path.posix.join(selected, 'subminer');
@@ -283,7 +304,29 @@ export async function detectLauncher(
const launcherResourcePath = resolveLauncherResourcePath(options);
const appExePath = options.appExePath ?? process.execPath;
if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
if (options.bundledBunPath && existsSyncOf(options)(expectedPath)) {
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
if (!isManagedLauncher(content)) {
return {
...target,
status: 'not_installed',
message: 'Reinstall the launcher to use the runtime included with SubMiner.',
};
}
if (
content !==
managedLauncherContent({
platform,
appPath: envOf(options).APPIMAGE ?? appExePath,
})
) {
return {
...target,
status: 'not_installed',
message: 'Reinstall the launcher to refresh its SubMiner location.',
};
}
} else if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
if (!shimMatchesCurrentInstall(content, appExePath, launcherResourcePath)) {
return {
@@ -305,26 +348,19 @@ export async function detectLauncher(
}
if (!existsSyncOf(options)(expectedPath))
return { ...target, status: 'not_installed', commandPath: null };
if (!commandPath) {
return {
...target,
status: 'not_on_path',
commandPath: expectedPath,
message: 'Launcher exists but its directory is not on PATH.',
};
}
const bunSnapshot = options.bunSnapshot ?? (await detectBun(options));
if (bunSnapshot.status !== 'ready') {
return {
...target,
status: 'installed_bun_missing',
commandPath,
message: 'Launcher is installed, but Bun is missing. Install Bun, then open a new terminal.',
message: options.bundledBunPath
? bunSnapshot.message
: 'Launcher is installed, but Bun is missing. Install Bun, then open a new terminal.',
};
}
const result = await getRunCommand(options)(commandPath, ['--help'], {
const result = await getRunCommand(options)(expectedPath, ['--help'], {
timeoutMs: COMMAND_TIMEOUT_MS,
env: envOf(options) as NodeJS.ProcessEnv,
});
@@ -336,6 +372,16 @@ export async function detectLauncher(
message: failureMessage(result, 'subminer --help failed'),
};
}
if (!commandPath) {
return {
...target,
status: 'not_on_path',
commandPath: expectedPath,
message:
target.message ??
`Launcher installed. Add ${target.pathDir} to your terminal PATH: export PATH=${shellQuote(target.pathDir ?? '')}:"$PATH". Save this in your shell configuration for future terminals.`,
};
}
return { ...target, status: 'ready', commandPath, message: null };
}
@@ -354,6 +400,48 @@ export async function installLauncher(
};
}
if (options.bundledBunPath) {
const bun = await detectBun(options);
if (bun.status !== 'ready')
return {
...target,
status: 'failed',
message: bun.message ?? 'The included launcher runtime failed to start.',
};
try {
stageManagedLauncher({
...options,
bundledBunPath: options.bundledBunPath,
launcherResourcePath,
force: true,
});
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.writeFileSync ?? fs.writeFileSync)(
target.installPath,
managedLauncherContent({
platform,
appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath,
}),
);
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
if (platform === 'win32') {
cleanupOldWindowsManagedRuntimes(options);
const nextPath = await appendWindowsUserPathDir(target.pathDir, options);
if (nextPath && options.env) {
options.env.PATH = nextPath;
options.env.Path = nextPath;
}
}
return await detectLauncher({ ...options, bunSnapshot: bun });
} catch (error) {
return {
...target,
status: 'failed',
message: error instanceof Error ? error.message : String(error),
};
}
}
if (platform === 'win32') {
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.writeFileSync ?? fs.writeFileSync)(
@@ -375,6 +463,8 @@ export async function installLauncher(
};
}
} else {
if (!existsSyncOf(options)(target.pathDir))
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.copyFileSync ?? fs.copyFileSync)(launcherResourcePath, target.installPath);
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
}
@@ -384,6 +474,7 @@ export async function installLauncher(
export async function installBun(
options: CommonOptions & WindowsPathOptions = {},
): Promise<BunSnapshot> {
if (options.bundledBunPath) return detectBun(options);
const platform = platformOf(options);
if (platform === 'win32') {
const bunDir = defaultBunRepairPath(options);
@@ -455,6 +546,84 @@ export async function installBun(
};
}
// Runs at app startup. Migrates recognized launchers in the standard bin dirs,
// the setup install target, and any paths a deferred update handed over.
// Returns paths that were refreshed or are no longer eligible for migration.
export async function refreshManagedCommandLineLauncher(
options: CommonOptions & WindowsPathOptions & { additionalLauncherPaths?: string[] },
): Promise<string[]> {
if (!options.bundledBunPath) return [];
const target = await resolveLauncherInstallTarget(options);
const platform = platformOf(options);
const platformPath = pathModuleFor(platform);
// cmd.exe reads a batch file incrementally while it runs, so the launcher that
// started this app is left alone until a later app start rewrites it.
const runningLauncherPath =
platform === 'win32' ? envOf(options).SUBMINER_LAUNCHER_PATH : undefined;
const isRunningLauncher = (candidate: string) =>
runningLauncherPath !== undefined &&
platformPath.normalize(candidate).toLowerCase() ===
platformPath.normalize(runningLauncherPath).toLowerCase();
const candidates = new Set([
...(target.installPath ? [target.installPath] : []),
...(options.additionalLauncherPaths ?? []),
...(platform === 'win32'
? []
: preferredLauncherDirs(platform, options.homeDir ?? os.homedir()).map((directory) =>
path.posix.join(directory, 'subminer'),
)),
]);
const readFile = options.readFileSync ?? fs.readFileSync;
const acknowledgedPaths: string[] = [];
let payload: ReturnType<typeof stageManagedLauncher> | undefined;
for (const candidate of candidates) {
if (isRunningLauncher(candidate)) continue;
if (!existsSyncOf(options)(candidate)) {
acknowledgedPaths.push(candidate);
continue;
}
let existing: string;
try {
existing = String(readFile(candidate, 'utf8'));
} catch {
continue;
}
const legacy =
(existing.startsWith('#!/usr/bin/env bun\n') &&
(existing.includes('SubMiner launcher') ||
existing.includes('Launch MPV with SubMiner'))) ||
(platform === 'win32' &&
existing ===
windowsShimContent(
options.appExePath ?? process.execPath,
resolveLauncherResourcePath(options).replace(/subminer\.js$/, 'subminer'),
));
if (!isManagedLauncher(existing) && !legacy) {
acknowledgedPaths.push(candidate);
continue;
}
if (!isWritableDir(pathModuleFor(platform).dirname(candidate), options)) continue;
try {
accessSyncOf(options)(candidate, fs.constants.W_OK);
} catch {
continue;
}
payload ??= stageManagedLauncher({
...options,
bundledBunPath: options.bundledBunPath,
launcherResourcePath: resolveLauncherResourcePath(options),
});
const content = managedLauncherContent({
platform,
appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath,
});
if (existing !== content) (options.writeFileSync ?? fs.writeFileSync)(candidate, content);
acknowledgedPaths.push(candidate);
}
if (platform === 'win32' && payload) cleanupOldWindowsManagedRuntimes(options);
return acknowledgedPaths;
}
export async function detectCommandLineLauncher(
options: CommonOptions & WindowsPathOptions = {},
): Promise<CommandLineLauncherSnapshot> {
@@ -52,6 +52,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
},
@@ -156,6 +156,7 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
const config = deepCloneConfig(DEFAULT_CONFIG);
config.ankiConnect.media.normalizeAudio = false;
config.ankiConnect.media.mirrorMpvVolume = false;
config.ankiConnect.media.reviewTiming = true;
const ankiPatches: unknown[] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
@@ -181,10 +182,18 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
},
config,
);
applyHotReload(
{
hotReloadFields: ['ankiConnect.media.reviewTiming'],
restartRequiredFields: [],
},
config,
);
assert.deepEqual(ankiPatches, [
{ media: { normalizeAudio: false } },
{ media: { mirrorMpvVolume: false } },
{ media: { reviewTiming: true } },
]);
});
@@ -100,6 +100,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.media.mirrorMpvVolume')) {
mediaPatch.mirrorMpvVolume = config.ankiConnect.media.mirrorMpvVolume;
}
if (diff.hotReloadFields.includes('ankiConnect.media.reviewTiming')) {
mediaPatch.reviewTiming = config.ankiConnect.media.reviewTiming;
}
if (Object.keys(mediaPatch).length > 0) {
patch.media = mediaPatch;
}
@@ -271,7 +271,7 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
});
test('buildFirstRunSetupHtml renders command-line launcher section and actions', () => {
test('buildFirstRunSetupHtml reports a broken included runtime in the optional launcher controls', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
@@ -294,9 +294,9 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
status: 'failed',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: 'network failed',
installMethod: null,
installCommand: null,
message: 'Included Bun runtime is missing.',
},
launcher: {
status: 'installed_bun_missing',
@@ -311,14 +311,11 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
});
assert.match(html, /Command line launcher/);
assert.match(html, /Optional\. Setup can finish without Bun or the launcher\./);
assert.match(html, /Bun runtime/);
assert.match(html, /Optional\. Install the launcher to use SubMiner from your terminal\./);
assert.match(html, /Failed/);
assert.match(html, /bash -lc curl -fsSL https:\/\/bun\.com\/install \| bash/);
assert.match(html, /Install Bun/);
assert.match(html, /action=install-bun/);
assert.match(html, /SubMiner launcher/);
assert.match(html, /Installed, Bun missing/);
assert.match(html, /Reinstall SubMiner to repair it/);
assert.match(html, /<button disabled[^>]+action=install-command-line-launcher/);
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
assert.match(html, /action=install-command-line-launcher/);
assert.match(
@@ -360,7 +357,7 @@ test('buildFirstRunSetupHtml disables launcher install when no target is install
assert.match(
html,
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install launcher<\/button>/,
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install command-line launcher<\/button>/,
);
});
+13 -64
View File
@@ -1,9 +1,5 @@
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
import type {
BunSnapshot,
CommandLineLauncherSnapshot,
LauncherSnapshot,
} from './command-line-launcher';
import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher';
type FocusableWindowLike = {
focus: () => void;
@@ -74,29 +70,12 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
return `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
}
function formatCommand(command: string[] | null): string {
return command?.join(' ') ?? 'No install command detected';
}
function getBunStatusLabel(status: BunSnapshot['status']): string {
switch (status) {
case 'ready':
return 'Ready';
case 'installing':
return 'Installing';
case 'failed':
return 'Failed';
case 'missing':
return 'Missing';
}
}
function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
switch (status) {
case 'ready':
return 'Ready';
case 'installed_bun_missing':
return 'Installed, Bun missing';
return 'Unavailable';
case 'not_installed':
return 'Not installed';
case 'not_on_path':
@@ -110,13 +89,6 @@ function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
}
}
function getToolTone(status: BunSnapshot['status']): 'ready' | 'warn' | 'muted' | 'danger' {
if (status === 'ready') return 'ready';
if (status === 'failed') return 'danger';
if (status === 'installing') return 'muted';
return 'warn';
}
function getLauncherTone(
status: LauncherSnapshot['status'],
): 'ready' | 'warn' | 'muted' | 'danger' {
@@ -135,49 +107,26 @@ function renderCommandLineLauncherSection(
const bun = commandLineLauncher.bun;
const launcher = commandLineLauncher.launcher;
const bunMeta =
bun.status === 'ready'
? [
bun.commandPath ? `Path: ${bun.commandPath}` : null,
bun.version ? `Version: ${bun.version}` : null,
].filter(Boolean)
: [
bun.installMethod ? `Method: ${bun.installMethod}` : null,
`Command: ${formatCommand(bun.installCommand)}`,
bun.message,
].filter(Boolean);
const runtimeUnavailable = bun.status !== 'ready';
const launcherStatus = runtimeUnavailable ? 'failed' : launcher.status;
const runtimeError = bun.installCommand
? "The launcher runtime is unavailable. Install the project's Bun dependency, then refresh."
: 'The launcher runtime is unavailable. Reinstall SubMiner to repair it.';
const launcherMeta = [
launcher.commandPath ? `Command: ${launcher.commandPath}` : null,
launcher.installPath ? `Install target: ${launcher.installPath}` : null,
launcher.pathDir ? `PATH dir: ${launcher.pathDir}` : null,
launcher.shadowedBy ? `Shadowed by: ${launcher.shadowedBy}` : null,
launcher.message,
bun.status !== 'ready' ? 'Warning: subminer will not run until Bun is available.' : null,
runtimeUnavailable ? runtimeError : launcher.message,
].filter(Boolean);
const bunInstallButton =
bun.status === 'missing' || bun.status === 'failed'
? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>`
: '';
const launcherButtonDisabled = launcher.status === 'not_installable' ? 'disabled' : '';
const launcherButtonDisabled =
launcher.status === 'not_installable' || bun.status !== 'ready' ? 'disabled' : '';
return `
<section class="setup-section">
<div class="section-head">
<h2>Command line launcher</h2>
<div class="meta">Optional. Setup can finish without Bun or the launcher.</div>
</div>
<div class="card block">
<div class="card-head">
<div>
<strong>Bun runtime</strong>
${bunMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
</div>
${renderStatusBadge(getBunStatusLabel(bun.status), getToolTone(bun.status))}
</div>
<div class="inline-actions">
${bunInstallButton}
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
</div>
<div class="meta">Optional. Install the launcher to use SubMiner from your terminal.</div>
</div>
<div class="card block">
<div class="card-head">
@@ -185,10 +134,10 @@ function renderCommandLineLauncherSection(
<strong>SubMiner launcher</strong>
${launcherMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
</div>
${renderStatusBadge(getLauncherStatusLabel(launcher.status), getLauncherTone(launcher.status))}
${renderStatusBadge(getLauncherStatusLabel(launcherStatus), getLauncherTone(launcherStatus))}
</div>
<div class="inline-actions">
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install launcher</button>
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install command-line launcher</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
</div>
</div>
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
+412
View File
@@ -0,0 +1,412 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import test from 'node:test';
import {
createUpdateStateStore,
takePendingLauncherMigrationPath,
type UpdateState,
} from './update/update-service';
import {
detectBun,
installBun,
installLauncher,
refreshManagedCommandLineLauncher,
} from './command-line-launcher';
import {
cleanupOldWindowsManagedRuntimes,
MANAGED_LAUNCHER_MARKER,
managedLauncherContent,
managedLauncherPaths,
stageManagedLauncher,
windowsManagedRuntimePaths,
} from './managed-launcher';
function workspace(t: test.TestContext) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "subminer bundled bun's "));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
test('a missing packaged Bun never falls back to system Bun or runs an installer', async () => {
let calls = 0;
const options = {
bundledBunPath: '/missing/private/bun',
env: { PATH: path.dirname(process.execPath) },
existsSync: (candidate: string) => candidate === process.execPath,
runCommand: async () => {
calls += 1;
return { exitCode: 0, stdout: '1.3.5', stderr: '' };
},
};
for (const snapshot of [await detectBun(options), await installBun(options)]) {
assert.equal(snapshot.status, 'missing');
assert.equal(snapshot.installCommand, null);
assert.match(snapshot.message ?? '', /Reinstall SubMiner/);
}
assert.equal(calls, 0);
});
test('packaged POSIX launcher works without Bun on PATH and survives AppImage unmount', async (t) => {
if (process.platform !== 'linux') return;
const root = workspace(t);
const resources = path.join(root, 'mounted resources');
const bin = path.join(root, 'home', '.local', 'bin');
fs.mkdirSync(resources, { recursive: true });
fs.mkdirSync(bin, { recursive: true });
const launcherResourcePath = path.join(resources, 'subminer');
const bundledBunPath = path.join(resources, 'bun');
fs.symlinkSync(process.execPath, bundledBunPath);
fs.writeFileSync(
launcherResourcePath,
'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));',
);
const appPath = path.join(root, 'SubMiner.AppImage');
fs.writeFileSync(appPath, '#!/bin/sh\nexit 73\n', { mode: 0o755 });
const options = {
platform: process.platform,
homeDir: path.join(root, 'home'),
env: {
HOME: path.join(root, 'home'),
PATH: bin,
XDG_DATA_HOME: path.join(root, 'data'),
APPIMAGE: appPath,
},
appExePath: path.join(resources, 'SubMiner'),
appVersion: '1.0.0',
bundledBunPath,
launcherResourcePath,
};
const installed = await installLauncher(options);
assert.equal(installed.status, 'ready', installed.message ?? 'install failed');
assert.match(fs.readFileSync(path.join(bin, 'subminer'), 'utf8'), /SubMiner managed launcher/);
fs.renameSync(resources, path.join(root, 'unmounted'));
const args = ['file with spaces.mkv', "single'quote", '$HOME', '$(touch nope)', '日本語'];
const result = spawnSync(path.join(bin, 'subminer'), args, {
env: options.env,
encoding: 'utf8',
timeout: 15000,
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), { args, app: appPath, managed: '1' });
});
test('setup can install into a new user bin despite an empty GUI PATH', async (t) => {
if (process.platform === 'win32') return;
const root = workspace(t);
const appPath = path.join(root, 'SubMiner.app', 'Contents', 'MacOS', 'SubMiner');
const resources = path.join(root, 'SubMiner.app', 'Contents', 'Resources');
fs.mkdirSync(path.dirname(appPath), { recursive: true });
fs.mkdirSync(path.join(resources, 'bun'), { recursive: true });
fs.mkdirSync(path.join(resources, 'launcher'), { recursive: true });
fs.writeFileSync(appPath, '#!/bin/sh\nexit 73\n', { mode: 0o755 });
fs.symlinkSync(process.execPath, path.join(resources, 'bun', 'bun'));
const script = path.join(resources, 'launcher', 'subminer.js');
fs.writeFileSync(script, 'console.log("help");');
const snapshot = await installLauncher({
platform: 'darwin',
homeDir: root,
env: { PATH: '' },
appExePath: appPath,
bundledBunPath: process.execPath,
launcherResourcePath: script,
});
assert.equal(snapshot.status, 'not_on_path', snapshot.message ?? 'install failed');
assert.equal(snapshot.installPath, path.join(root, '.local', 'bin', 'subminer'));
assert.match(snapshot.message ?? '', /export PATH=/);
const result = spawnSync(snapshot.installPath!, ['--help'], {
env: { PATH: '' },
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr);
});
test('app upgrades refresh payloads, migrate legacy Bun launchers, and preserve custom scripts', async (t) => {
if (process.platform !== 'linux') return;
const root = workspace(t);
const bin = path.join(root, 'bin');
fs.mkdirSync(bin, { recursive: true });
const script = path.join(root, 'resource');
fs.writeFileSync(script, 'console.log("old");');
const appPath = path.join(root, 'SubMiner.AppImage');
fs.writeFileSync(appPath, '#!/bin/sh\nexit 73\n', { mode: 0o755 });
const options = {
platform: process.platform,
homeDir: root,
env: { HOME: root, PATH: bin },
appVersion: '1',
appExePath: appPath,
bundledBunPath: process.execPath,
launcherResourcePath: script,
};
assert.equal((await installLauncher(options)).status, 'ready');
fs.writeFileSync(script, 'console.log("new");');
await refreshManagedCommandLineLauncher({
...options,
env: { HOME: root, PATH: '' },
appVersion: '2',
});
const payload = managedLauncherPaths(options);
assert.equal(fs.readFileSync(payload.scriptPath, 'utf8'), 'console.log("new");');
fs.writeFileSync(path.join(bin, 'subminer'), '#!/usr/bin/env bun\n// SubMiner launcher\n');
await refreshManagedCommandLineLauncher({ ...options, appVersion: '3' });
assert.match(fs.readFileSync(path.join(bin, 'subminer'), 'utf8'), /SubMiner managed launcher/);
const deferred = path.join(root, 'custom', 'subminer');
fs.mkdirSync(path.dirname(deferred));
fs.writeFileSync(deferred, '#!/usr/bin/env bun\n// SubMiner launcher\n');
const unreadable = path.join(root, 'unreadable');
fs.mkdirSync(unreadable);
const acknowledged = await refreshManagedCommandLineLauncher({
...options,
appVersion: '3',
additionalLauncherPaths: [unreadable, deferred],
});
assert.ok(!acknowledged.includes(unreadable));
assert.ok(acknowledged.includes(deferred));
assert.match(fs.readFileSync(deferred, 'utf8'), /SubMiner managed launcher/);
fs.writeFileSync(path.join(bin, 'subminer'), '#!/bin/sh\necho standalone\n');
const customAcknowledged = await refreshManagedCommandLineLauncher({
...options,
appVersion: '4',
});
assert.ok(customAcknowledged.includes(path.join(bin, 'subminer')));
assert.equal(fs.readFileSync(payload.versionPath, 'utf8'), '3');
});
test('Windows startup never rewrites the batch launcher that started the app', async () => {
const programs = 'C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner';
const installPath = 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin\\subminer.cmd';
const writes: string[] = [];
let state: UpdateState = { pendingLauncherMigrationPath: installPath };
const store = createUpdateStateStore({
readState: async () => state,
writeState: async (nextState) => {
state = nextState;
},
});
const options = {
platform: 'win32' as const,
localAppData: 'C:\\Users\\tester\\AppData\\Local',
appVersion: '1.2.3',
appExePath: `${programs}\\SubMiner.exe`,
bundledBunPath: `${programs}\\resources\\bun\\bun.exe`,
launcherResourcePath: `${programs}\\resources\\launcher\\subminer.js`,
existsSync: (candidate: string) => !candidate.includes('licenses'),
accessSync: () => {},
mkdirSync: () => undefined,
copyFileSync: () => {},
readFileSync: () =>
managedLauncherContent({ platform: 'win32', appPath: 'D:\\Old\\SubMiner.exe' }),
writeFileSync: (candidate: string) => {
writes.push(candidate);
},
};
await takePendingLauncherMigrationPath(store, async (pendingPath) => {
const acknowledged = await refreshManagedCommandLineLauncher({
...options,
additionalLauncherPaths: pendingPath ? [pendingPath] : [],
env: { SUBMINER_LAUNCHER_PATH: installPath.toUpperCase() },
});
return pendingPath !== undefined && acknowledged.includes(pendingPath);
});
assert.deepEqual(writes, []);
assert.equal(state.pendingLauncherMigrationPath, installPath);
await takePendingLauncherMigrationPath(store, async (pendingPath) => {
const acknowledged = await refreshManagedCommandLineLauncher({
...options,
additionalLauncherPaths: pendingPath ? [pendingPath] : [],
env: {},
});
assert.equal(state.pendingLauncherMigrationPath, installPath);
return pendingPath !== undefined && acknowledged.includes(pendingPath);
});
assert.deepEqual(writes, [installPath]);
assert.equal(state.pendingLauncherMigrationPath, undefined);
});
test('Windows wrapper discovers the configured app and its versioned private runtime', () => {
const content = managedLauncherContent({
platform: 'win32',
appPath: 'C:\\Apps 100% !\\SubMiner.exe',
});
assert.ok(content.includes(MANAGED_LAUNCHER_MARKER));
assert.ok(content.includes('setlocal DisableDelayedExpansion'));
assert.ok(content.includes('set "SUBMINER_BINARY_PATH=C:\\Apps 100%% !\\SubMiner.exe"'));
assert.ok(content.includes('%SUBMINER_RESOURCES_PATH%\\launcher\\version'));
assert.ok(
content.includes(
'set "SUBMINER_BUN_PATH=%LOCALAPPDATA%\\SubMiner\\launcher-runtime\\%SUBMINER_APP_VERSION%\\bun.exe"',
),
);
assert.ok(
content.includes('"%SUBMINER_BUN_PATH%" "%SUBMINER_RESOURCES_PATH%\\launcher\\subminer.js" %*'),
);
assert.ok(content.includes('exit /b %errorlevel%'));
});
test('Windows managed runtime path is absolute, versioned, and injectable', () => {
const paths = windowsManagedRuntimePaths({
platform: 'win32',
localAppData: 'D:\\Profiles\\テスト User\\AppData\\Local',
appVersion: '1.2.3-beta.4',
});
assert.equal(
paths.bunPath,
'D:\\Profiles\\テスト User\\AppData\\Local\\SubMiner\\launcher-runtime\\1.2.3-beta.4\\bun.exe',
);
assert.ok(path.win32.isAbsolute(paths.bunPath));
assert.throws(
() =>
windowsManagedRuntimePaths({
platform: 'win32',
localAppData: 'relative',
appVersion: '1.2.3',
}),
/must be an absolute Windows path/,
);
assert.throws(
() =>
windowsManagedRuntimePaths({
platform: 'win32',
localAppData: 'C:\\Users\\tester\\AppData\\Local',
appVersion: '1:2',
}),
/not a valid directory name/,
);
});
test('Windows managed launcher forwards arguments without a system Bun', async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const appDirectory = path.join(root, 'Installed App');
const appPath = path.join(appDirectory, 'SubMiner.exe');
const launcherDirectory = path.join(appDirectory, 'resources', 'launcher');
const script = path.join(launcherDirectory, 'subminer.js');
fs.mkdirSync(launcherDirectory, { recursive: true });
fs.copyFileSync(process.execPath, appPath);
fs.writeFileSync(script, 'console.log(JSON.stringify(process.argv.slice(2)));');
fs.writeFileSync(path.join(launcherDirectory, 'version'), '1.0.0');
const options = {
platform: process.platform,
env: { ...process.env, PATH: '', LOCALAPPDATA: root },
bundledBunPath: process.execPath,
launcherResourcePath: script,
appExePath: appPath,
appVersion: '1.0.0',
localAppData: root,
getUserPath: () => '',
setUserPath: () => {},
broadcastEnvironmentChange: () => {},
};
const snapshot = await installLauncher(options);
assert.equal(snapshot.status, 'ready', snapshot.message ?? 'install failed');
const { getRunCommand } = await import('./command-line-launcher-deps');
const args = ['spaces here', 'a&b', 'p%TEMP%q', 'bang!z', 'say "hi"', '日本語'];
const result = await getRunCommand({})(snapshot.installPath!, args, { env: options.env });
assert.equal(result.exitCode, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), args);
});
test('Windows stages a new runtime version while the prior Bun executable is running', async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const bundledDirectory = path.join(root, 'packaged', 'bun');
const bundledBunPath = path.join(bundledDirectory, 'bun.exe');
const launcherResourcePath = path.join(root, 'packaged', 'launcher', 'subminer');
fs.mkdirSync(path.join(bundledDirectory, 'licenses'), { recursive: true });
fs.mkdirSync(path.dirname(launcherResourcePath), { recursive: true });
fs.copyFileSync(process.execPath, bundledBunPath);
fs.writeFileSync(path.join(bundledDirectory, 'licenses', 'Bun-LICENSE.md'), 'license');
fs.writeFileSync(launcherResourcePath, 'console.log("launcher");');
const first = stageManagedLauncher({
platform: 'win32',
localAppData: root,
appVersion: '1.0.0',
bundledBunPath,
launcherResourcePath,
});
const running = spawn(first.bunPath, ['-e', 'setInterval(() => {}, 1000)']);
await new Promise<void>((resolve, reject) => {
running.once('spawn', resolve);
running.once('error', reject);
});
const expectedSecond = windowsManagedRuntimePaths({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
try {
const second = stageManagedLauncher({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
bundledBunPath,
launcherResourcePath,
});
assert.notEqual(second.bunPath, first.bunPath);
assert.ok(fs.existsSync(second.bunPath));
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
assert.ok(fs.existsSync(first.bunPath));
assert.ok(fs.existsSync(path.join(path.dirname(first.bunPath), 'licenses', 'Bun-LICENSE.md')));
assert.equal(
fs.readFileSync(
path.join(path.dirname(second.bunPath), 'licenses', 'Bun-LICENSE.md'),
'utf8',
),
'license',
);
} finally {
if (running.exitCode === null) {
const exited = new Promise<void>((resolve) => running.once('exit', () => resolve()));
running.kill();
await exited;
}
}
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
assert.equal(fs.existsSync(path.dirname(first.bunPath)), false);
assert.ok(fs.existsSync(expectedSecond.bunPath));
});
test('Windows cleanup removes an obsolete runtime with no Bun executable', (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const current = windowsManagedRuntimePaths({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
const obsolete = windowsManagedRuntimePaths({
platform: 'win32',
localAppData: root,
appVersion: '1.0.0',
});
fs.mkdirSync(path.join(path.dirname(obsolete.bunPath), 'licenses'), { recursive: true });
fs.writeFileSync(
path.join(path.dirname(obsolete.bunPath), 'licenses', 'Bun-LICENSE.md'),
'license',
);
fs.mkdirSync(path.dirname(current.bunPath), { recursive: true });
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
assert.equal(fs.existsSync(path.dirname(obsolete.bunPath)), false);
assert.ok(fs.existsSync(path.dirname(current.bunPath)));
});
+219
View File
@@ -0,0 +1,219 @@
import { randomUUID } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { windowsLauncherBootstrapContent } from './windows-launcher-bootstrap';
import { MANAGED_LAUNCHER_MARKER, posixLauncherBootstrapContent } from './posix-launcher-bootstrap';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
envOf,
existsSyncOf,
pathModuleFor,
platformOf,
type CommonOptions,
type WindowsPathOptions,
} from './command-line-launcher-deps';
export { MANAGED_LAUNCHER_MARKER, shellQuote } from './posix-launcher-bootstrap';
export function isManagedLauncher(content: string): boolean {
const lines = content.split(/\r?\n/, 3);
return (
(lines[0] === '#!/bin/sh' && lines[1] === `# ${MANAGED_LAUNCHER_MARKER}`) ||
(lines[0] === '@echo off' && lines[1] === `rem ${MANAGED_LAUNCHER_MARKER}`)
);
}
export function managedLauncherContent(options: {
platform: NodeJS.Platform;
appPath: string;
}): string {
if (options.platform === 'win32') return windowsLauncherBootstrapContent(options.appPath);
return posixLauncherBootstrapContent(options.appPath);
}
export function managedLauncherPaths(options: CommonOptions) {
const platform = platformOf(options);
const platformPath = pathModuleFor(platform);
const env = envOf(options);
const home = options.homeDir ?? os.homedir();
const dataHome = env.XDG_DATA_HOME;
const directory = platformPath.join(
dataHome && platformPath.isAbsolute(dataHome)
? dataHome
: platformPath.join(home, '.local', 'share'),
'SubMiner',
'launcher',
);
return {
directory,
bunPath: platformPath.join(directory, 'bun'),
scriptPath: platformPath.join(directory, 'subminer'),
versionPath: platformPath.join(directory, 'version'),
fingerprintPath: platformPath.join(directory, 'fingerprint'),
appPathFile: platformPath.join(directory, 'app-path'),
};
}
function absoluteWindowsPath(candidate: string, label: string): string {
const normalized = path.win32.normalize(candidate);
if (!path.win32.isAbsolute(normalized)) {
throw new Error(`${label} must be an absolute Windows path: ${candidate}`);
}
return normalized;
}
export function windowsManagedRuntimePaths(options: CommonOptions & WindowsPathOptions) {
const env = envOf(options);
const userProfile = options.userProfile?.trim() || env.USERPROFILE?.trim() || os.homedir();
const localAppData =
options.localAppData?.trim() ||
env.LOCALAPPDATA?.trim() ||
path.win32.join(userProfile, 'AppData', 'Local');
const rootDirectory = path.win32.join(
absoluteWindowsPath(localAppData, 'Windows local app data directory'),
'SubMiner',
'launcher-runtime',
);
const version = options.appVersion?.trim() || 'development';
if (
version === '.' ||
version === '..' ||
/[<>:"/\\|?*\u0000-\u001f]/.test(version) ||
/[ .]$/.test(version)
) {
throw new Error(`SubMiner version is not a valid directory name: ${version}`);
}
const directory = path.win32.join(rootDirectory, version);
return {
rootDirectory,
directory,
bunPath: path.win32.join(directory, 'bun.exe'),
};
}
export function cleanupOldWindowsManagedRuntimes(
options: CommonOptions & WindowsPathOptions,
): void {
const paths = windowsManagedRuntimePaths(options);
try {
for (const entry of fs.readdirSync(paths.rootDirectory, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === path.win32.basename(paths.directory)) continue;
const oldDirectory = path.win32.join(paths.rootDirectory, entry.name);
try {
fs.rmSync(path.win32.join(oldDirectory, 'bun.exe'), { force: true });
} catch {
continue;
}
try {
fs.rmSync(oldDirectory, { recursive: true, force: true });
} catch {
// Cleanup is best-effort after proving the old runtime is not locked.
}
}
} catch {
// The runtime root can be absent before the first managed launcher install.
}
}
function stageWindowsManagedRuntime(
options: CommonOptions &
WindowsPathOptions & {
bundledBunPath: string;
force?: boolean;
},
): string {
const paths = windowsManagedRuntimePaths(options);
const exists = existsSyncOf(options);
const mkdir = options.mkdirSync ?? fs.mkdirSync;
const copy = options.copyFileSync ?? fs.copyFileSync;
mkdir(paths.directory, { recursive: true });
if (options.force || !exists(paths.bunPath)) {
const stagingPath = `${paths.bunPath}.${randomUUID()}.tmp`;
try {
copy(options.bundledBunPath, stagingPath);
if (exists(paths.bunPath)) fs.rmSync(paths.bunPath, { force: true });
fs.renameSync(stagingPath, paths.bunPath);
} finally {
fs.rmSync(stagingPath, { force: true });
}
}
const packagedLicenses = path.join(path.dirname(options.bundledBunPath), 'licenses');
const cachedLicenses = path.win32.join(paths.directory, 'licenses');
if (exists(packagedLicenses) && (options.force || !exists(cachedLicenses))) {
fs.cpSync(packagedLicenses, cachedLicenses, { recursive: true, force: true });
}
return paths.bunPath;
}
// Keep ephemeral or replaceable executables outside the installed app. Linux
// also copies the script because AppImage resources disappear when the app exits.
export function stageManagedLauncher(
options: CommonOptions &
WindowsPathOptions & {
bundledBunPath: string;
launcherResourcePath: string;
force?: boolean;
},
) {
const platform = platformOf(options);
if (platform === 'win32') {
return {
bunPath: stageWindowsManagedRuntime(options),
scriptPath: options.launcherResourcePath,
};
}
if (platform !== 'linux') {
return { bunPath: options.bundledBunPath, scriptPath: options.launcherResourcePath };
}
const paths = managedLauncherPaths(options);
const exists = existsSyncOf(options);
const read = options.readFileSync ?? fs.readFileSync;
const write = options.writeFileSync ?? fs.writeFileSync;
const copy = options.copyFileSync ?? fs.copyFileSync;
const mkdir = options.mkdirSync ?? fs.mkdirSync;
const chmod = options.chmodSync ?? fs.chmodSync;
const version = options.appVersion ?? 'development';
const appPath = envOf(options).APPIMAGE ?? options.appExePath;
const fingerprint = appPath
? execFileSync('stat', ['-Lc', '%d:%i:%s:%y:%z', '--', appPath], {
encoding: 'utf8',
env: { ...envOf(options), PATH: `/usr/bin:/bin:${envOf(options).PATH ?? ''}` },
}).trim()
: '';
if (
!options.force &&
exists(paths.versionPath) &&
read(paths.versionPath, 'utf8') === version &&
exists(paths.fingerprintPath) &&
read(paths.fingerprintPath, 'utf8') === `${fingerprint}\n` &&
exists(paths.appPathFile) &&
read(paths.appPathFile, 'utf8') === `${appPath ?? ''}\n` &&
exists(paths.bunPath) &&
exists(paths.scriptPath)
)
return paths;
mkdir(paths.directory, { recursive: true });
const staging = fs.mkdtempSync(path.join(paths.directory, '.stage-'));
try {
copy(options.bundledBunPath, path.join(staging, 'bun'));
chmod(path.join(staging, 'bun'), 0o755);
copy(options.launcherResourcePath, path.join(staging, 'subminer'));
const notices = path.join(path.dirname(options.bundledBunPath), 'licenses');
if (exists(notices))
fs.cpSync(notices, path.join(paths.directory, 'licenses'), { recursive: true });
write(path.join(staging, 'version'), version);
write(path.join(staging, 'app-path'), `${appPath ?? ''}\n`);
write(path.join(staging, 'fingerprint'), `${fingerprint}\n`);
for (const name of ['bun', 'subminer', 'version', 'app-path', 'fingerprint']) {
fs.renameSync(path.join(staging, name), path.join(paths.directory, name));
}
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
return paths;
}
@@ -0,0 +1,42 @@
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const MODAL: OverlayHostedModal = 'media-timing-review';
export async function openMediaTimingReviewModal(
deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
},
payload: MediaTimingReviewOpenPayload,
): Promise<boolean> {
return await retryOverlayModalOpen(
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
{
modal: MODAL,
// The review renderer regularly needs more than the 1.5 s the other modals allow; a
// premature retry re-sends the payload and reloads the waveform for nothing.
timeoutMs: 4_000,
retryWarning:
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(deps, {
channel: IPC_CHANNELS.event.mediaTimingReviewOpen,
modal: MODAL,
payload,
preferModalWindow: true,
}),
},
);
}
@@ -0,0 +1,859 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
import type {
RemoteMediaWindow,
RemoteMediaWindowRange,
RemoteMediaWindowSource,
} from '../../core/services/remote-media-window-cache';
import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
type MediaTimingPreviewSessionLike = Pick<MediaTimingPreviewSession, 'start'>;
import {
buildMediaTimingReviewPayload,
collectMediaTimingContextLines,
createMediaTimingReviewRuntime,
} from './media-timing-review';
describe('buildMediaTimingReviewPayload', () => {
test('starts from the padded range and leaves two seconds to drag on each side', () => {
const payload = buildMediaTimingReviewPayload(
{
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
},
{ reviewId: 'review-1', mediaDuration: 100 },
);
assert.equal(payload.selectionStartTime, 9.5);
assert.equal(payload.selectionEndTime, 12.5);
assert.equal(payload.timelineStartTime, 7.5);
assert.equal(payload.timelineEndTime, 14.5);
});
test('clamps the padded selection and timeline to media bounds', () => {
const payload = buildMediaTimingReviewPayload(
{
kind: 'word',
text: '字幕',
startTime: 0.2,
endTime: 9.8,
audioPadding: 1,
maxMediaDuration: 30,
},
{ reviewId: 'review-2', mediaDuration: 10 },
);
assert.equal(payload.selectionStartTime, 0);
assert.equal(payload.selectionEndTime, 10);
assert.equal(payload.timelineStartTime, 0);
assert.equal(payload.timelineEndTime, 10);
});
test('keeps an uncapped selection when max media duration is disabled', () => {
const payload = buildMediaTimingReviewPayload(
{
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 55,
audioPadding: 1,
maxMediaDuration: 0,
},
{ reviewId: 'review-unlimited', mediaDuration: 100 },
);
assert.equal(payload.selectionStartTime, 9);
assert.equal(payload.selectionEndTime, 56);
assert.equal(payload.maxMediaDuration, 0);
});
});
async function startActiveMediaTimingReview(
options: {
maxMediaDuration?: number;
decisionTimeoutMs?: number;
generateWaveform?: () => Promise<number[]>;
play?: () => Promise<void>;
} = {},
) {
const previewCalls: Array<[number, number]> = [];
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve;
});
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
send: () => undefined,
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: options.generateWaveform ?? (async () => []),
decisionTimeoutMs: options.decisionTimeoutMs,
createPreviewSession: () => ({
start: async () => undefined,
play: async (startTime, endTime) => {
previewCalls.push([startTime, endTime]);
await options.play?.();
},
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
publishPayload(payload);
return true;
},
showStatus: () => undefined,
});
const pendingDecision = runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: options.maxMediaDuration ?? 30,
});
return { runtime, payload: await openedPayload, pendingDecision, previewCalls };
}
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
const commands: Array<Array<string | number>> = [];
const previewCalls: Array<[number, number]> = [];
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) =>
({ pause: false, duration: 100, aid: 2, volume: 60 })[
name as 'pause' | 'duration' | 'aid' | 'volume'
],
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async (startTime, endTime) => {
previewCalls.push([startTime, endTime]);
},
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
queueMicrotask(() => {
void runtime
.previewRange({
reviewId: payload.reviewId,
startTime: 9.5,
endTime: 12.5,
})
.then(() => {
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 9.5, endTime: 12.5 },
});
});
});
return true;
},
showStatus: () => undefined,
});
const decision = await runtime.requestReview({
kind: 'word',
text: '字幕',
startTime: 10,
endTime: 12,
noteId: 42,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.deepEqual(decision, { action: 'confirm', startTime: 9.5, endTime: 12.5 });
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
assert.deepEqual(previewCalls, [[9.5, 12.5]]);
});
test('media timing review analyzes the visible range on the selected audio stream', async () => {
const waveformCalls: SpeechWaveformOptions[] = [];
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
currentAudioStreamIndex: 4,
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
send: () => undefined,
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async (options) => {
waveformCalls.push(options);
return [0.1, 0.8, 0.2];
},
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
const waveform = await runtime.getWaveform({
reviewId: payload.reviewId,
startTime: payload.timelineStartTime,
endTime: payload.timelineEndTime,
});
assert.deepEqual(waveform, { ok: true, peaks: [0.1, 0.8, 0.2] });
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'use-original' },
});
return true;
},
showStatus: () => undefined,
});
await runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.deepEqual(waveformCalls, [
{
mediaPath: '/video/show.mkv',
startTime: 7.5,
endTime: 14.5,
audioStreamIndex: 4,
},
]);
});
const REMOTE_STREAM_URL = 'https://jellyfin.example/Videos/abc/stream?static=true';
function createWindowStub(options: { fail?: boolean } = {}) {
const calls: Array<{ source: RemoteMediaWindowSource; range: RemoteMediaWindowRange }> = [];
const acquireMediaWindow = async (
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow> => {
calls.push({ source, range });
if (options.fail) throw new Error('offline');
const windowPath = `/tmp/window-${range.startTime}-${range.endTime}.mkv`;
return {
path: windowPath,
startTime: range.startTime,
endTime: range.endTime,
sourcePath: source.path,
audioStreamIndex: source.audioStreamIndex ?? null,
media: {
path: windowPath,
source: 'remote-window',
singleResolvedStream: true,
absoluteTimestamps: true,
},
};
};
return { calls, acquireMediaWindow };
}
function createRemoteReviewRuntime(options: {
windowStub: ReturnType<typeof createWindowStub>;
waveformCalls: SpeechWaveformOptions[];
previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]>;
previewPlays: Array<[string, number, number]>;
disposed: string[];
openModal: (
runtime: ReturnType<typeof createMediaTimingReviewRuntime>,
payload: MediaTimingReviewOpenPayload,
) => Promise<void>;
}) {
let runtime!: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: REMOTE_STREAM_URL,
currentAudioStreamIndex: 2,
requestProperty: async (name) =>
({ pause: true, duration: 100, aid: 3, volume: 60 })[
name as 'pause' | 'duration' | 'aid' | 'volume'
] ?? null,
send: () => undefined,
}),
getCurrentMediaPath: () => REMOTE_STREAM_URL,
getMpvExecutablePath: () => 'mpv',
resolveMediaSource: async () => ({
path: REMOTE_STREAM_URL,
inputOptions: { reconnect: true },
}),
acquireMediaWindow: options.windowStub.acquireMediaWindow,
generateWaveform: async (waveformOptions) => {
options.waveformCalls.push(waveformOptions);
return [0.1, 0.8, 0.2];
},
createPreviewSession: () => {
let mediaPath = '';
return {
start: async (startOptions) => {
mediaPath = startOptions.mediaPath;
options.previewStarts.push(startOptions);
},
play: async (startTime, endTime) => {
options.previewPlays.push([mediaPath, startTime, endTime]);
},
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => {
options.disposed.push(mediaPath);
},
};
},
openModal: async (payload) => {
await options.openModal(runtime, payload);
return true;
},
showStatus: () => undefined,
});
return runtime;
}
test('media timing review downloads one window of a remote stream for the waveform and preview', async () => {
const windowStub = createWindowStub();
const waveformCalls: SpeechWaveformOptions[] = [];
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
const previewPlays: Array<[string, number, number]> = [];
const disposed: string[] = [];
const runtime = createRemoteReviewRuntime({
windowStub,
waveformCalls,
previewStarts,
previewPlays,
disposed,
openModal: async (active, payload) => {
const waveform = await active.getWaveform({
reviewId: payload.reviewId,
startTime: payload.timelineStartTime,
endTime: payload.timelineEndTime,
});
assert.deepEqual(waveform, { ok: true, peaks: [0.1, 0.8, 0.2] });
assert.deepEqual(
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 }),
{ ok: true },
);
active.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 9.5, endTime: 12.5 },
});
},
});
const decision = await runtime.requestReview({
kind: 'word',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.deepEqual(decision, { action: 'confirm', startTime: 9.5, endTime: 12.5 });
assert.deepEqual(windowStub.calls, [
{
source: { path: REMOTE_STREAM_URL, inputOptions: { reconnect: true }, audioStreamIndex: 2 },
range: { startTime: 7.5, endTime: 14.5 },
},
]);
assert.deepEqual(waveformCalls, [
{
mediaPath: {
path: '/tmp/window-7.5-14.5.mkv',
source: 'remote-window',
singleResolvedStream: true,
absoluteTimestamps: true,
},
startTime: 7.5,
endTime: 14.5,
},
]);
assert.deepEqual(previewStarts, [
{
mediaPath: '/tmp/window-7.5-14.5.mkv',
executablePath: 'mpv',
volume: 60,
absoluteTimestamps: true,
},
]);
assert.deepEqual(previewPlays, [['/tmp/window-7.5-14.5.mkv', 9.5, 12.5]]);
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv']);
});
test('media timing review restarts the preview on a wider window when the timeline grows', async () => {
const windowStub = createWindowStub();
const waveformCalls: SpeechWaveformOptions[] = [];
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
const previewPlays: Array<[string, number, number]> = [];
const disposed: string[] = [];
const runtime = createRemoteReviewRuntime({
windowStub,
waveformCalls,
previewStarts,
previewPlays,
disposed,
openModal: async (active, payload) => {
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 });
// The user revealed two more seconds before the clip.
await active.getWaveform({ reviewId: payload.reviewId, startTime: 5.5, endTime: 14.5 });
await active.previewRange({ reviewId: payload.reviewId, startTime: 6, endTime: 12.5 });
active.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
},
});
await runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.deepEqual(
windowStub.calls.map((call) => call.range),
[
{ startTime: 7.5, endTime: 14.5 },
{ startTime: 5.5, endTime: 14.5 },
],
);
assert.deepEqual(
previewStarts.map((start) => start.mediaPath),
['/tmp/window-7.5-14.5.mkv', '/tmp/window-5.5-14.5.mkv'],
);
assert.deepEqual(previewPlays, [
['/tmp/window-7.5-14.5.mkv', 9.5, 12.5],
['/tmp/window-5.5-14.5.mkv', 6, 12.5],
]);
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv', '/tmp/window-5.5-14.5.mkv']);
assert.equal(waveformCalls[0]?.startTime, 5.5);
});
test('media timing review falls back to the remote stream after one failed window download', async () => {
const windowStub = createWindowStub({ fail: true });
const waveformCalls: SpeechWaveformOptions[] = [];
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
const previewPlays: Array<[string, number, number]> = [];
const disposed: string[] = [];
const runtime = createRemoteReviewRuntime({
windowStub,
waveformCalls,
previewStarts,
previewPlays,
disposed,
openModal: async (active, payload) => {
await active.getWaveform({
reviewId: payload.reviewId,
startTime: payload.timelineStartTime,
endTime: payload.timelineEndTime,
});
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 });
active.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
},
});
await runtime.requestReview({
kind: 'word',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.equal(windowStub.calls.length, 1);
assert.deepEqual(waveformCalls, [
{
mediaPath: { path: REMOTE_STREAM_URL, inputOptions: { reconnect: true } },
startTime: 7.5,
endTime: 14.5,
audioStreamIndex: 2,
},
]);
assert.deepEqual(previewStarts, [
{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 },
]);
assert.deepEqual(previewPlays, [[REMOTE_STREAM_URL, 9.5, 12.5]]);
});
test('media timing review never downloads windows for local media', async () => {
const windowStub = createWindowStub();
let runtime!: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
send: () => undefined,
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
resolveMediaSource: async () => ({ path: '/video/show.mkv' }),
acquireMediaWindow: windowStub.acquireMediaWindow,
generateWaveform: async () => [0.1, 0.8, 0.2],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 7.5, endTime: 14.5 });
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
return true;
},
showStatus: () => undefined,
});
await runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.equal(windowStub.calls.length, 0);
});
test('media timing review rejects stale and out-of-range actions before allowing discard', async () => {
const { runtime, payload, pendingDecision, previewCalls } = await startActiveMediaTimingReview({
maxMediaDuration: 3,
});
assert.deepEqual(
await runtime.previewRange({ reviewId: 'stale-review', startTime: 10, endTime: 12 }),
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
);
assert.deepEqual(
runtime.resolveReview({
reviewId: 'stale-review',
decision: { action: 'confirm', startTime: 10, endTime: 12 },
}),
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
);
assert.deepEqual(
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 10, endTime: 14 },
}),
{ ok: false, message: 'The selected timing range is invalid.' },
);
assert.deepEqual(
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 99, endTime: 100.5 },
}),
{ ok: false, message: 'The selected timing range is invalid.' },
);
assert.deepEqual(
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'confirm', startTime: 10, endTime: 12, text: ' ' },
}),
{ ok: false, message: 'The combined sentence text is invalid.' },
);
assert.deepEqual(
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'discard' } }),
{ ok: true },
);
assert.deepEqual(await pendingDecision, { action: 'discard' });
assert.deepEqual(previewCalls, []);
});
test('collectMediaTimingContextLines splits cues around the mined range', () => {
const cues = [
{ text: '一行目', startTime: 0, endTime: 2 },
{ text: '二行目', startTime: 2.5, endTime: 4 },
{ text: '', startTime: 4.2, endTime: 4.4 },
{ text: '採掘行', startTime: 5, endTime: 7 },
{ text: '四行目', startTime: 7.5, endTime: 9 },
{ text: '五行目', startTime: 9.5, endTime: 11 },
];
const context = collectMediaTimingContextLines({ cues, startTime: 5, endTime: 7 });
assert.deepEqual(context.previous, [
{ text: '一行目', startTime: 0, endTime: 2 },
{ text: '二行目', startTime: 2.5, endTime: 4 },
]);
assert.deepEqual(context.next, [
{ text: '四行目', startTime: 7.5, endTime: 9 },
{ text: '五行目', startTime: 9.5, endTime: 11 },
]);
});
test('collectMediaTimingContextLines falls back to played history when no cues are loaded', () => {
const context = collectMediaTimingContextLines({
cues: [],
fallbackPrevious: [
{ displayText: '前の行', startTime: 1, endTime: 2 },
{ displayText: '採掘行', startTime: 5, endTime: 7 },
],
startTime: 5,
endTime: 7,
});
assert.deepEqual(context.previous, [{ text: '前の行', startTime: 1, endTime: 2 }]);
assert.deepEqual(context.next, []);
});
test('media timing review watchdog falls back when the renderer stops responding', async () => {
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
assert.deepEqual(await pendingDecision, { action: 'use-original' });
});
test('media timing review does not resume playback when the prior state is unavailable', async () => {
const commands: Array<Array<string | number>> = [];
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async () => null,
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => '',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => {
throw new Error('preview unavailable');
},
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
queueMicrotask(() => {
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'use-original' },
});
});
return true;
},
showStatus: () => undefined,
});
assert.deepEqual(
await runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
}),
{ action: 'use-original' },
);
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
});
test('media timing review restores playback when setup fails after pausing', async () => {
const commands: Array<Array<string | number>> = [];
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'pause' ? false : null),
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => {
throw new Error('preview setup failed');
},
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async () => true,
showStatus: () => undefined,
});
assert.deepEqual(
await runtime.requestReview({
kind: 'word',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
}),
{ action: 'use-original' },
);
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
});
test('disposing an open review settles it with original timing and restores playback', async () => {
const commands: Array<Array<string | number>> = [];
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'pause' ? false : null),
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async () => true,
showStatus: () => undefined,
});
const pending = runtime.requestReview({
kind: 'word',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
});
await new Promise<void>((resolve) => setImmediate(resolve));
await runtime.dispose();
assert.deepEqual(await pending, { action: 'use-original' });
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
});
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
const endedReviewIds: string[] = [];
const playback: { ended?: () => void } = {};
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve;
});
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : null),
send: () => undefined,
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: (listener) => {
playback.ended = listener;
},
dispose: () => undefined,
}),
openModal: async (payload) => {
publishPayload(payload);
return true;
},
onPreviewEnded: (reviewId) => {
endedReviewIds.push(reviewId);
},
showStatus: () => undefined,
});
const pendingDecision = runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
});
const payload = await openedPayload;
assert.deepEqual(
await runtime.previewRange({ reviewId: payload.reviewId, startTime: 10, endTime: 12 }),
{
ok: true,
},
);
assert.ok(playback.ended);
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
});
test('preview reports a stale review when the review ends during playback', async () => {
let endReview: (() => Promise<void>) | null = null;
const { runtime, payload, pendingDecision } = await startActiveMediaTimingReview({
play: async () => {
await endReview?.();
},
});
endReview = () => runtime.dispose();
assert.deepEqual(
await runtime.previewRange({ reviewId: payload.reviewId, startTime: 10, endTime: 12 }),
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
);
await pendingDecision;
});
test('waveform reports a stale review when the review ends during analysis', async () => {
let endReview: (() => Promise<void>) | null = null;
const { runtime, payload, pendingDecision } = await startActiveMediaTimingReview({
generateWaveform: async () => {
await endReview?.();
return [0.1, 0.9, 0.2];
},
});
endReview = () => runtime.dispose();
assert.deepEqual(
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 8, endTime: 14 }),
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
);
await pendingDecision;
});
+589
View File
@@ -0,0 +1,589 @@
import { randomUUID } from 'crypto';
import type {
MediaTimingReviewActionResult,
MediaTimingReviewContextLine,
MediaTimingReviewDecision,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewRequest,
MediaTimingReviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
import {
isRemoteMediaWindowSourcePath,
type RemoteMediaWindow,
type RemoteMediaWindowRange,
type RemoteMediaWindowSource,
} from '../../core/services/remote-media-window-cache';
import type { MediaInput, MediaInputOptions } from '../../media-input';
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
const REVIEW_DECISION_TIMEOUT_MS = 5 * 60_000;
const CONTEXT_LINE_LIMIT = 12;
const CONTEXT_LINE_EPSILON_SECONDS = 0.05;
interface ReviewMpvClient {
connected: boolean;
currentVideoPath: string;
currentAudioStreamIndex?: number | null;
requestProperty?: (name: string) => Promise<unknown>;
send: (payload: { command: Array<string | number> }) => void;
}
interface PreviewSession {
start(options: {
mediaPath: string;
executablePath?: string;
audioTrackId?: number;
volume?: number;
absoluteTimestamps?: boolean;
}): Promise<void>;
play(startTime: number, endTime: number): Promise<void>;
stop(): Promise<void>;
/** Fires when the player reaches the end of the clip started by play(). */
onPlaybackEnded(listener: () => void): void;
dispose(): void;
}
interface ReviewMediaSource {
path: string;
inputOptions?: MediaInputOptions;
singleResolvedStream?: boolean;
}
interface ActiveReview {
payload: MediaTimingReviewOpenPayload;
/** What the hidden mpv preview plays when no cached window is available. */
mediaPath: string;
/** What the waveform reads when no cached window is available. */
waveformMedia: MediaInput;
audioStreamIndex?: number;
/** Remote source to download windows of; null for local media or without a cache. */
windowSource: RemoteMediaWindowSource | null;
/** Latest window returned for this review; reused while it still covers the request. */
window: RemoteMediaWindow | null;
windowRequest: (RemoteMediaWindowRange & { promise: Promise<RemoteMediaWindow | null> }) | null;
windowFailed: boolean;
previewOptions: { executablePath?: string; audioTrackId?: number; volume?: number };
preview: { path: string; session: Promise<PreviewSession> } | null;
mpvClient: ReviewMpvClient;
restorePlayback: boolean;
resolve: (decision: MediaTimingReviewDecision) => void;
}
export interface MediaTimingReviewRuntimeDeps {
getMpvClient: () => ReviewMpvClient | null;
getCurrentMediaPath: () => string | null;
getMpvExecutablePath: () => string;
createPreviewSession: () => PreviewSession;
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
/** Resolves the FFmpeg-readable stream URL and headers behind the current media path. */
resolveMediaSource?: () => Promise<ReviewMediaSource | null>;
/** Downloads (or reuses) a local window of a remote source covering the range. */
acquireMediaWindow?: (
source: RemoteMediaWindowSource,
range: RemoteMediaWindowRange,
) => Promise<RemoteMediaWindow>;
getSubtitleContextLines?: (range: { startTime: number; endTime: number }) => {
previous: MediaTimingReviewContextLine[];
next: MediaTimingReviewContextLine[];
};
decisionTimeoutMs?: number;
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
/** Tells the modal that the hidden player finished the previewed clip. */
onPreviewEnded?: (reviewId: string) => void;
showStatus: (message: string) => void;
}
function finiteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function booleanProperty(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (value === 'yes' || value === 1) return true;
if (value === 'no' || value === 0) return false;
return null;
}
/**
* Picks the subtitle lines adjacent to the mined range that the review modal can pull
* onto the card. Parsed cues cover both directions; when none are loaded (e.g. the
* active track was never parsed) the timing tracker's history still provides the
* lines that already played, so only "next" is unavailable.
*/
export function collectMediaTimingContextLines(options: {
cues: readonly { text: string; startTime: number; endTime: number }[];
fallbackPrevious?: readonly { displayText: string; startTime: number; endTime: number }[];
startTime: number;
endTime: number;
}): { previous: MediaTimingReviewContextLine[]; next: MediaTimingReviewContextLine[] } {
const usable = options.cues
.filter(
(cue) =>
cue.text.trim().length > 0 &&
Number.isFinite(cue.startTime) &&
Number.isFinite(cue.endTime) &&
cue.endTime > cue.startTime,
)
.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
let previous = usable
.filter((cue) => cue.endTime <= options.startTime + CONTEXT_LINE_EPSILON_SECONDS)
.slice(-CONTEXT_LINE_LIMIT)
.map(({ text, startTime, endTime }) => ({ text: text.trim(), startTime, endTime }));
const next = usable
.filter((cue) => cue.startTime >= options.endTime - CONTEXT_LINE_EPSILON_SECONDS)
.slice(0, CONTEXT_LINE_LIMIT)
.map(({ text, startTime, endTime }) => ({ text: text.trim(), startTime, endTime }));
if (previous.length === 0 && options.fallbackPrevious) {
previous = options.fallbackPrevious
.filter(
(entry) =>
entry.displayText.trim().length > 0 &&
Number.isFinite(entry.startTime) &&
Number.isFinite(entry.endTime) &&
entry.endTime > entry.startTime &&
entry.endTime <= options.startTime + CONTEXT_LINE_EPSILON_SECONDS,
)
.slice(-CONTEXT_LINE_LIMIT)
.map((entry) => ({
text: entry.displayText.trim(),
startTime: entry.startTime,
endTime: entry.endTime,
}));
}
return { previous, next };
}
/**
* Result for requests that name a review main has already resolved or disposed (decision
* watchdog, overlay teardown, duplicate modal). The renderer closes on it instead of
* leaving the user with controls that can never succeed.
*/
function staleReviewResult(): MediaTimingReviewActionResult {
return { ok: false, stale: true, message: 'This timing review is no longer active.' };
}
function isValidMediaTimingRange(
payload: MediaTimingReviewOpenPayload,
startTime: number,
endTime: number,
): boolean {
return (
Number.isFinite(startTime) &&
Number.isFinite(endTime) &&
startTime >= 0 &&
endTime > startTime &&
(payload.maxMediaDuration <= 0 || endTime - startTime <= payload.maxMediaDuration + 0.001) &&
(payload.mediaDuration === undefined || endTime <= payload.mediaDuration + 0.001)
);
}
export function buildMediaTimingReviewPayload(
request: MediaTimingReviewRequest,
options: {
reviewId: string;
mediaDuration?: number;
contextLines?: {
previous: MediaTimingReviewContextLine[];
next: MediaTimingReviewContextLine[];
};
},
): MediaTimingReviewOpenPayload {
const duration = finiteNumber(options.mediaDuration);
const maxTime = duration !== null && duration > 0 ? duration : Number.POSITIVE_INFINITY;
const paddedStart = Math.max(0, request.startTime - request.audioPadding);
let paddedEnd = Math.min(maxTime, request.endTime + request.audioPadding);
const maxMediaDuration = Math.max(0, request.maxMediaDuration);
if (maxMediaDuration > 0 && paddedEnd - paddedStart > maxMediaDuration) {
paddedEnd = paddedStart + maxMediaDuration;
}
if (paddedEnd <= paddedStart) {
paddedEnd = Math.min(maxTime, paddedStart + 0.1);
}
const timelineStartTime = Math.max(0, paddedStart - INITIAL_TIMELINE_MARGIN_SECONDS);
const timelineEndTime = Math.max(
paddedEnd,
Math.min(maxTime, paddedEnd + INITIAL_TIMELINE_MARGIN_SECONDS),
);
return {
reviewId: options.reviewId,
kind: request.kind,
text: request.text,
previousLines: options.contextLines?.previous ?? [],
nextLines: options.contextLines?.next ?? [],
...(request.noteId !== undefined ? { noteId: request.noteId } : {}),
originalStartTime: request.startTime,
originalEndTime: request.endTime,
selectionStartTime: paddedStart,
selectionEndTime: paddedEnd,
timelineStartTime,
timelineEndTime,
...(duration !== null && duration > 0 ? { mediaDuration: duration } : {}),
maxMediaDuration,
};
}
export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) {
let active: ActiveReview | null = null;
let reviewInProgress = false;
let pendingPauseRestore: ReviewMpvClient | null = null;
function restorePendingPlayback(): void {
const mpvClient = pendingPauseRestore;
pendingPauseRestore = null;
if (mpvClient?.connected) {
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
}
function ensureWindow(
review: ActiveReview,
range: RemoteMediaWindowRange,
): Promise<RemoteMediaWindow | null> {
const { windowSource } = review;
if (!windowSource || review.windowFailed || !deps.acquireMediaWindow) {
return Promise.resolve(null);
}
const coversRange = (candidate: RemoteMediaWindowRange): boolean =>
candidate.startTime <= range.startTime && candidate.endTime >= range.endTime;
if (review.window && coversRange(review.window)) return Promise.resolve(review.window);
const inFlight = review.windowRequest;
if (inFlight && coversRange(inFlight)) return inFlight.promise;
const request = {
startTime: range.startTime,
endTime: range.endTime,
promise: Promise.resolve<RemoteMediaWindow | null>(null),
};
request.promise = deps
.acquireMediaWindow(windowSource, { startTime: range.startTime, endTime: range.endTime })
.then((window) => {
review.window = window;
return window;
})
.catch(() => {
// Fall back to the remote source for the rest of this review instead of retrying.
review.windowFailed = true;
return null;
})
.finally(() => {
if (review.windowRequest === request) review.windowRequest = null;
});
review.windowRequest = request;
return request.promise;
}
/**
* Returns the preview player for the range, restarting it when the range needs a
* different file (the first cached window, or a wider one after the timeline grew).
*/
async function previewFor(
review: ActiveReview,
range: RemoteMediaWindowRange,
): Promise<PreviewSession> {
const window = await ensureWindow(review, range);
if (active !== review) {
// The review ended during the download; do not start a player nobody will dispose.
throw new Error('This timing review is no longer active.');
}
const mediaPath = window?.path ?? review.mediaPath;
if (review.preview?.path === mediaPath) return review.preview.session;
const previous = review.preview;
const session = deps.createPreviewSession();
session.onPlaybackEnded(() => {
if (active === review && review.preview?.session === started) {
deps.onPreviewEnded?.(review.payload.reviewId);
}
});
const { audioTrackId, ...previewOptions } = review.previewOptions;
const started = session
.start({
mediaPath,
...previewOptions,
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
...(window
? { absoluteTimestamps: true }
: audioTrackId !== undefined
? { audioTrackId }
: {}),
})
.then(() => session)
.catch((error) => {
session.dispose();
throw error;
});
review.preview = { path: mediaPath, session: started };
void started.catch(() => {});
if (previous) void previous.session.then((old) => old.dispose()).catch(() => {});
return started;
}
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> {
const mpvClient = deps.getMpvClient();
const mediaPath =
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
if (!mpvClient?.connected || !mediaPath) {
deps.showStatus('Timing review unavailable. Using the original subtitle timing.');
return { action: 'use-original' };
}
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource] = await Promise.all([
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
]);
const pauseState = booleanProperty(pauseRaw);
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
pendingPauseRestore = pauseState === false ? mpvClient : null;
let contextLines: ReturnType<NonNullable<typeof deps.getSubtitleContextLines>> | undefined;
try {
contextLines = deps.getSubtitleContextLines?.({
startTime: request.startTime,
endTime: request.endTime,
});
} catch {
contextLines = undefined;
}
const payload = buildMediaTimingReviewPayload(request, {
reviewId: randomUUID(),
mediaDuration: finiteNumber(durationRaw) ?? undefined,
...(contextLines ? { contextLines } : {}),
});
const sourcePath = resolvedSource?.path.trim() || mediaPath;
const inputOptions = resolvedSource?.inputOptions;
const audioStreamIndex =
resolvedSource?.singleResolvedStream || mpvClient.currentAudioStreamIndex == null
? undefined
: mpvClient.currentAudioStreamIndex;
const windowSource: RemoteMediaWindowSource | null =
deps.acquireMediaWindow && isRemoteMediaWindowSourcePath(sourcePath)
? {
path: sourcePath,
...(inputOptions ? { inputOptions } : {}),
audioStreamIndex: audioStreamIndex ?? null,
}
: null;
let resolveDecision!: (decision: MediaTimingReviewDecision) => void;
const decisionPromise = new Promise<MediaTimingReviewDecision>((resolve) => {
resolveDecision = resolve;
});
const review: ActiveReview = {
payload,
mediaPath,
waveformMedia: inputOptions ? { path: sourcePath, inputOptions } : sourcePath,
...(audioStreamIndex !== undefined ? { audioStreamIndex } : {}),
windowSource,
window: null,
windowRequest: null,
windowFailed: false,
previewOptions: {
executablePath: deps.getMpvExecutablePath(),
audioTrackId: finiteNumber(audioTrackRaw) ?? undefined,
volume: finiteNumber(volumeRaw) ?? undefined,
},
preview: null,
mpvClient,
restorePlayback: pendingPauseRestore === mpvClient,
resolve: resolveDecision,
};
active = review;
pendingPauseRestore = null;
// Download the visible timeline once now; the waveform and preview both wait on it.
void previewFor(review, {
startTime: payload.timelineStartTime,
endTime: payload.timelineEndTime,
}).catch(() => {});
const opened = await deps.openModal(payload).catch(() => false);
if (!opened) {
await cleanupActiveReview();
deps.showStatus('Timing review could not open. Using the original subtitle timing.');
return { action: 'use-original' };
}
const decisionWatchdog = setTimeout(
() => resolveDecision({ action: 'use-original' }),
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
);
let decision: MediaTimingReviewDecision;
try {
decision = await decisionPromise;
} finally {
clearTimeout(decisionWatchdog);
}
await cleanupActiveReview();
return decision;
}
async function requestReview(
request: MediaTimingReviewRequest,
): Promise<MediaTimingReviewDecision> {
if (active || reviewInProgress) {
deps.showStatus('Finish the current timing review before mining another card.');
return { action: 'use-original' };
}
reviewInProgress = true;
try {
return await runReview(request);
} catch {
await cleanupActiveReview();
restorePendingPlayback();
deps.showStatus('Timing review failed. Using the original subtitle timing.');
return { action: 'use-original' };
} finally {
reviewInProgress = false;
}
}
async function previewRange(
request: MediaTimingReviewPreviewRequest,
): Promise<MediaTimingReviewActionResult> {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) {
return staleReviewResult();
}
if (!isValidMediaTimingRange(current.payload, request.startTime, request.endTime)) {
return { ok: false, message: 'The selected preview range is invalid.' };
}
try {
const previewSession = await previewFor(current, request);
if (active !== current) {
return staleReviewResult();
}
await previewSession.play(request.startTime, request.endTime);
// Playback spans the whole clip, so the review can end (watchdog, teardown) while
// it runs; reporting success would leave the modal open on a dead review.
if (active !== current) {
return staleReviewResult();
}
return { ok: true };
} catch (error) {
if (active !== current) {
return staleReviewResult();
}
return {
ok: false,
message: `Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function getWaveform(
request: MediaTimingReviewWaveformRequest,
): Promise<MediaTimingReviewWaveformResult> {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) {
return staleReviewResult();
}
if (
!Number.isFinite(request.startTime) ||
!Number.isFinite(request.endTime) ||
request.startTime < 0 ||
request.endTime <= request.startTime ||
(current.payload.mediaDuration !== undefined &&
request.endTime > current.payload.mediaDuration + 0.001)
) {
return { ok: false, message: 'The waveform range is invalid.' };
}
try {
const window = await ensureWindow(current, request);
if (active !== current) {
return staleReviewResult();
}
const peaks = await deps.generateWaveform({
mediaPath: window?.media ?? current.waveformMedia,
startTime: request.startTime,
endTime: request.endTime,
...(!window && current.audioStreamIndex !== undefined
? { audioStreamIndex: current.audioStreamIndex }
: {}),
});
// ffmpeg decoding runs long enough for the review to end underneath it.
if (active !== current) {
return staleReviewResult();
}
if (peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
return { ok: false, message: 'Timing waveform is unavailable.' };
}
return { ok: true, peaks };
} catch {
if (active !== current) {
return staleReviewResult();
}
return { ok: false, message: 'Timing waveform is unavailable.' };
}
}
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
const current = active;
if (!current || reviewId !== current.payload.reviewId) {
return staleReviewResult();
}
try {
const previewSession = current.preview ? await current.preview.session : null;
await previewSession?.stop();
return { ok: true };
} catch (error) {
return {
ok: false,
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
function resolveReview(request: MediaTimingReviewResolveRequest): MediaTimingReviewActionResult {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) {
return staleReviewResult();
}
if (request.decision.action === 'confirm') {
const { startTime, endTime, text } = request.decision;
if (!isValidMediaTimingRange(current.payload, startTime, endTime)) {
return { ok: false, message: 'The selected timing range is invalid.' };
}
if (text !== undefined && (typeof text !== 'string' || text.trim().length === 0)) {
return { ok: false, message: 'The combined sentence text is invalid.' };
}
}
current.resolve(request.decision);
return { ok: true };
}
async function cleanupActiveReview(): Promise<void> {
const current = active;
active = null;
if (!current) return;
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
if (current.restorePlayback && current.mpvClient.connected) {
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
}
async function dispose(): Promise<void> {
active?.resolve({ action: 'use-original' });
await cleanupActiveReview();
restorePendingPlayback();
}
return {
requestReview,
previewRange,
getWaveform,
stopPreview,
resolveReview,
dispose,
};
}
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readMpvInputBindings } from './mpv-input-bindings';
test('discovery reads the connected player and preserves configured keys including disabled bindings', async () => {
const client = {
connected: true,
requestProperty: async (name: string) => {
assert.equal(name, 'input-bindings');
return [{ key: 'r', cmd: 'script-binding replay/run', priority: 1 }];
},
};
assert.deepEqual(
await readMpvInputBindings({
getMpvClient: () => client,
getConfiguredKeybindings: () => [{ key: 'Ctrl+KeyR', command: null }],
platform: 'linux',
}),
{ keys: ['r'], blockedKeys: [{ code: 'KeyR', modifiers: ['ctrl'] }] },
);
});
test('discovery safely handles unsupported properties and disconnects during a request', async () => {
const client = {
connected: true,
requestProperty: async (): Promise<unknown> => {
throw new Error('property unavailable');
},
};
const deps = {
getMpvClient: () => client,
getConfiguredKeybindings: () => [],
platform: 'linux',
} satisfies Parameters<typeof readMpvInputBindings>[0];
assert.deepEqual((await readMpvInputBindings(deps)).keys, []);
client.requestProperty = async () => {
client.connected = false;
return [{ key: 'r', cmd: 'seek 5', priority: 1 }];
};
assert.deepEqual((await readMpvInputBindings(deps)).keys, []);
});
+31
View File
@@ -0,0 +1,31 @@
import type { Keybinding } from '../../types';
import { parseSessionBindingKey } from '../../core/services/session-bindings';
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
export async function readMpvInputBindings(deps: {
getMpvClient: () => {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
} | null;
getConfiguredKeybindings: () => Keybinding[];
platform: 'darwin' | 'win32' | 'linux';
}): Promise<MpvInputBindingsSnapshot> {
const blockedKeys = deps.getConfiguredKeybindings().flatMap((binding) => {
const { key } = parseSessionBindingKey(binding.key, deps.platform);
return key ? [key] : [];
});
const client = deps.getMpvClient();
if (!client?.connected) return { keys: [], blockedKeys };
try {
const value = await client.requestProperty('input-bindings');
return {
keys:
client === deps.getMpvClient() && client.connected ? parseMpvInputBindingKeys(value) : [],
blockedKeys,
};
} catch {
// Older mpv versions and disconnected sessions retain SubMiner's controls.
return { keys: [], blockedKeys };
}
}
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import { posixLauncherBootstrapContent, shellQuote } from './posix-launcher-bootstrap';
test('downloaded launcher prepares once, survives unmount, and refreshes after app replacement', (t) => {
if (process.platform !== 'linux') return;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "subminer bootstrap's "));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const appPath = path.join(root, 'SubMiner.AppImage');
const resources = path.join(root, 'mount', 'resources');
fs.mkdirSync(path.join(resources, 'launcher'), { recursive: true });
fs.mkdirSync(path.join(resources, 'bun', 'licenses'), { recursive: true });
fs.symlinkSync(process.execPath, path.join(resources, 'bun', 'bun'));
fs.writeFileSync(path.join(resources, 'bun', 'licenses', 'notice'), 'Bun notice');
fs.writeFileSync(path.join(resources, 'launcher', 'version'), '1.0.0\n');
fs.writeFileSync(
path.join(resources, 'launcher', 'prepare.cjs'),
`exports.prepareLauncherRuntime = require(${JSON.stringify(path.join(__dirname, 'prepare-launcher-runtime.ts'))}).prepareLauncherRuntime;`,
);
const script = (version: number) =>
`console.log(JSON.stringify({version:${version},args:process.argv.slice(2)}));`;
fs.writeFileSync(path.join(resources, 'launcher', 'subminer.js'), script(1));
const prepares = path.join(root, 'preparations');
const appContent = `#!/bin/sh\necho prepared >> ${shellQuote(prepares)}\nexport APPDIR=${shellQuote(path.dirname(resources))}\nexec ${shellQuote(process.execPath)} "$@"\n`;
fs.writeFileSync(appPath, appContent, { mode: 0o755 });
const wrapper = path.join(root, 'subminer');
fs.writeFileSync(wrapper, posixLauncherBootstrapContent(), { mode: 0o755 });
const env = { HOME: root, PATH: '', SUBMINER_BINARY_PATH: appPath };
const args = ['space here', "a'b", 'a&b', '$(touch nope)', '日本語'];
const run = (extraEnv = {}) =>
spawnSync(wrapper, args, { env: { ...env, ...extraEnv }, encoding: 'utf8' });
const first = run();
assert.equal(first.status, 0, first.stderr);
assert.deepEqual(JSON.parse(first.stdout), { version: 1, args });
const cache = path.join(root, '.local', 'share', 'SubMiner', 'launcher');
assert.equal(fs.readFileSync(path.join(cache, 'licenses', 'notice'), 'utf8'), 'Bun notice');
fs.renameSync(resources, `${resources}.unmounted`);
const warm = run({ SUBMINER_BINARY_PATH: '' }); // Finds the app recorded by preparation.
assert.equal(warm.status, 0, warm.stderr);
assert.deepEqual(JSON.parse(warm.stdout), { version: 1, args });
assert.equal(fs.readFileSync(prepares, 'utf8'), 'prepared\n');
fs.renameSync(`${resources}.unmounted`, resources);
// Same version and path, new inode: manual replacement must still refresh.
fs.writeFileSync(`${appPath}.new`, appContent, { mode: 0o755 });
fs.renameSync(`${appPath}.new`, appPath);
fs.writeFileSync(path.join(resources, 'launcher', 'subminer.js'), script(2));
const updated = run();
assert.equal(updated.status, 0, updated.stderr);
assert.deepEqual(JSON.parse(updated.stdout), { version: 2, args });
assert.equal(fs.readFileSync(prepares, 'utf8'), 'prepared\nprepared\n');
fs.unlinkSync(path.join(cache, 'bun'));
assert.equal(run().status, 0);
assert.equal(fs.readFileSync(prepares, 'utf8'), 'prepared\nprepared\nprepared\n');
fs.writeFileSync(appPath, '#!/bin/sh\nexit 42\n', { mode: 0o755 });
const failed = run();
assert.equal(failed.status, 42);
assert.equal(failed.stdout, ''); // Never silently execute stale CLI after failed preparation.
});
@@ -0,0 +1,56 @@
export const MANAGED_LAUNCHER_MARKER = 'SubMiner managed launcher (bundled runtime)';
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
// Only a missing or stale Linux cache starts Electron in Node mode. Normal
// launches do one stat and execute the cached Bun and matching CLI directly.
export function posixLauncherBootstrapContent(appPath = ''): string {
return `#!/bin/sh
# ${MANAGED_LAUNCHER_MARKER}
export SUBMINER_MANAGED_LAUNCHER=1
export SUBMINER_LAUNCHER_PATH="$0"
subminer_default_app=${shellQuote(appPath)}
case "\${XDG_DATA_HOME:-}" in
/*) subminer_data="$XDG_DATA_HOME" ;;
*) subminer_data="$HOME/.local/share" ;;
esac
subminer_cache="$subminer_data/SubMiner/launcher"
subminer_saved_app=
if [ -f "$subminer_cache/app-path" ]; then
IFS= read -r subminer_saved_app < "$subminer_cache/app-path" || :
fi
subminer_app=
for subminer_candidate in "\${SUBMINER_APPIMAGE_PATH:-}" "\${SUBMINER_BINARY_PATH:-}" "$subminer_default_app" "$subminer_saved_app" "$HOME/.local/bin/SubMiner.AppImage" /opt/SubMiner/SubMiner.AppImage /Applications/SubMiner.app/Contents/MacOS/SubMiner "$HOME/Applications/SubMiner.app/Contents/MacOS/SubMiner"; do
if [ -n "$subminer_candidate" ] && [ -x "$subminer_candidate" ]; then
subminer_app="$subminer_candidate"
break
fi
done
if [ -z "$subminer_app" ]; then
echo 'SubMiner app not found. Install the app or set SUBMINER_BINARY_PATH to its executable.' >&2
exit 1
fi
export SUBMINER_BINARY_PATH="$subminer_app"
case "$subminer_app" in
*/Contents/MacOS/*)
subminer_resources="\${subminer_app%/MacOS/*}/Resources"
if [ ! -x "$subminer_resources/bun/bun" ] || [ ! -f "$subminer_resources/launcher/subminer.js" ]; then
echo 'This launcher requires a SubMiner app with the included Bun runtime. Update SubMiner.' >&2
exit 1
fi
exec "$subminer_resources/bun/bun" "$subminer_resources/launcher/subminer.js" "$@"
;;
esac
subminer_fingerprint=$(PATH="/usr/bin:/bin:$PATH" stat -Lc '%d:%i:%s:%y:%z' -- "$subminer_app") || exit 1
subminer_cached_fingerprint=
if [ -f "$subminer_cache/fingerprint" ]; then
IFS= read -r subminer_cached_fingerprint < "$subminer_cache/fingerprint" || :
fi
if [ "$subminer_app" != "$subminer_saved_app" ] || [ "$subminer_fingerprint" != "$subminer_cached_fingerprint" ] || [ ! -x "$subminer_cache/bun" ] || [ ! -f "$subminer_cache/subminer" ]; then
PATH="/usr/bin:/bin:$PATH" ELECTRON_RUN_AS_NODE=1 "$subminer_app" -e 'const p=require("node:path"); const r=process.env.APPDIR ? p.join(process.env.APPDIR,"resources") : p.join(p.dirname(process.execPath),"resources"); try { require(p.join(r,"launcher/prepare.cjs")).prepareLauncherRuntime({appPath:process.env.SUBMINER_BINARY_PATH,resourcesPath:r}); } catch(e) { console.error("Cannot prepare SubMiner launcher. Update or reinstall the SubMiner app.",e.message); process.exit(1); }' || exit $?
fi
exec "$subminer_cache/bun" "$subminer_cache/subminer" "$@"
`;
}
@@ -0,0 +1,24 @@
import fs from 'node:fs';
import path from 'node:path';
import { cleanupOldWindowsManagedRuntimes, stageManagedLauncher } from './managed-launcher';
// Bundled separately as Node-compatible code so AppImages can prepare their
// runtime without starting Electron's GUI, single-instance lock, or settings.
export function prepareLauncherRuntime(options: { appPath: string; resourcesPath: string }) {
const appVersion = fs
.readFileSync(path.join(options.resourcesPath, 'launcher', 'version'), 'utf8')
.trim();
const payload = stageManagedLauncher({
appExePath: options.appPath,
appVersion,
bundledBunPath: path.join(
options.resourcesPath,
'bun',
process.platform === 'win32' ? 'bun.exe' : 'bun',
),
launcherResourcePath: path.join(options.resourcesPath, 'launcher', 'subminer.js'),
force: process.platform === 'linux',
});
if (process.platform === 'win32') cleanupOldWindowsManagedRuntimes({ appVersion });
return payload;
}
@@ -3,6 +3,7 @@ import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitle,
resolvePrimarySubtitleText,
stripCanonicalFragmentLines,
} from './primary-subtitle-text';
@@ -702,3 +703,37 @@ test('resolvePrimarySubtitleText publishes a wrapped caption sentence as one cue
'(東)≪好きだと\n自覚してしまったものの➡',
);
});
test('resolvePrimarySubtitle drops a finished caption row lingering beside a fresh line', () => {
// Broadcast captions give each row its own event, and a row of the previous line can
// outlive its siblings by a frame. mpv's sub-text still lists it, so the mined line
// must come from the parsed cue that is actually running, with that cue's timings.
const ass = [
'[Script Info]',
'PlayResY: 540',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:14:30.00,0:14:33.00,Default,,0,0,0,,{\\pos(232,437)\\fscx50}{\\fscx100}東{\\fscx50}{\\fscx100}ずっと 言えなかっ',
'Dialogue: 0,0:14:30.00,0:14:33.02,Default,,0,0,0,,{\\pos(232,497)}たが',
'Dialogue: 0,0:14:33.00,0:14:36.00,Default,,0,0,0,,{\\pos(232,437)}⸨もし お互い',
'Dialogue: 0,0:14:33.00,0:14:36.00,Default,,0,0,0,,{\\pos(232,497)}本命 受かったら 大学 近いし➡',
].join('\n');
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e09.ass');
const resolved = resolvePrimarySubtitle({
liveText: 'たが\n⸨もし お互い\n本命 受かったら 大学 近いし➡',
currentTimeSec: 14 * 60 + 33.05,
cues,
});
assert.deepEqual(
{ ...resolved, cues: resolved?.cues.map((cue) => cue.text) },
{
text: '⸨もし お互い\n本命 受かったら 大学 近いし➡',
startTime: 14 * 60 + 33,
endTime: 14 * 60 + 36,
cues: ['⸨もし お互い\n本命 受かったら 大学 近いし➡'],
},
);
});
+21 -9
View File
@@ -319,6 +319,26 @@ export function resolveRecordedPrimarySubtitleText(options: {
);
}
/**
* The parsed view of the live text with its cue timings: a canonical animation when one
* explains the live lines, otherwise the active parsed cues. Null when the parsed cues
* cannot account for every live line, in which case callers keep the raw mpv text.
*/
export function resolvePrimarySubtitle(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): ResolvedPrimarySubtitle | null {
const liveText = decodedLiveText(options.liveText, options.cues);
if (!liveText.trim()) {
return null;
}
return (
resolveCanonicalPrimarySubtitle({ ...options, liveText }) ??
resolveActiveParsedPrimarySubtitle({ ...options, liveText })
);
}
export function resolvePrimarySubtitleText(options: {
liveText: string;
currentTimeSec: number;
@@ -328,13 +348,5 @@ export function resolvePrimarySubtitleText(options: {
if (!liveText.trim()) {
return liveText;
}
return (
resolveCanonicalPrimarySubtitle({
liveText,
currentTimeSec: options.currentTimeSec,
cues: options.cues,
})?.text ??
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
removeLiveGlyphFragmentLines(liveText)
);
return resolvePrimarySubtitle(options)?.text ?? removeLiveGlyphFragmentLines(liveText);
}
@@ -0,0 +1,43 @@
import type { IpcMain, WebContents } from 'electron';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { isSubtitleGenerationModelId } from '../../shared/subtitle-generation-model-catalog';
import type { createSubtitleGenerationRuntime } from './subtitle-generation-runtime';
export function registerSubtitleGenerationIpc(deps: {
ipc: Pick<IpcMain, 'handle'>;
isAllowedSender: (sender: WebContents) => boolean;
openModal: () => Promise<boolean>;
runtime: ReturnType<typeof createSubtitleGenerationRuntime>;
}): void {
const handlers = [
[IPC_CHANNELS.request.requestSubtitleGenerationOpen, () => deps.openModal()],
[IPC_CHANNELS.request.getSubtitleGenerationStatus, () => deps.runtime.getStatus()],
[
IPC_CHANNELS.request.selectSubtitleGenerationModel,
(model: unknown) => {
if (!isSubtitleGenerationModelId(model))
throw new Error('Unknown subtitle generation model.');
return deps.runtime.selectModel(model);
},
],
[IPC_CHANNELS.request.startSubtitleGeneration, () => deps.runtime.start()],
[IPC_CHANNELS.request.downloadSubtitleGenerationModel, () => deps.runtime.download()],
[IPC_CHANNELS.request.downloadSubtitleGenerationVadModel, () => deps.runtime.downloadVad()],
[
IPC_CHANNELS.request.setSubtitleGenerationVadEnabled,
(enabled: unknown) => {
if (typeof enabled !== 'boolean')
throw new Error('Speech detection selection must be a boolean.');
return deps.runtime.setVadEnabled(enabled);
},
],
[IPC_CHANNELS.request.cancelSubtitleGeneration, () => deps.runtime.cancel()],
] as const;
for (const [channel, handler] of handlers) {
deps.ipc.handle(channel, (event, payload: unknown) => {
if (!deps.isAllowedSender(event.sender))
throw new Error('Subtitle generation is only available from the overlay.');
return handler(payload);
});
}
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openSubtitleGenerationModal } from './subtitle-generation-open';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
test('subtitle generation opens in the dedicated modal window with normal close restoration', async () => {
const calls: string[] = [];
const opened = await openSubtitleGenerationModal({
ensureOverlayStartupPrereqs: () => {
calls.push('startup');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('windows');
},
sendToActiveOverlayWindow: (channel, payload, options) => {
assert.deepEqual(calls, ['startup', 'windows']);
assert.equal(channel, IPC_CHANNELS.event.subtitleGenerationOpen);
assert.equal(payload, undefined);
assert.deepEqual(options, {
restoreOnModalClose: 'subtitle-generation',
preferModalWindow: true,
});
calls.push('open');
return true;
},
waitForModalOpen: async (modal) => {
assert.equal(modal, 'subtitle-generation');
return true;
},
logWarn: () => {
assert.fail('opening should not require a retry');
},
});
assert.equal(opened, true);
assert.deepEqual(calls, ['startup', 'windows', 'open']);
});
@@ -0,0 +1,18 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
export function openSubtitleGenerationModal(
deps: Parameters<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
): Promise<boolean> {
return retryOverlayModalOpen(deps, {
modal: 'subtitle-generation',
timeoutMs: 1500,
retryWarning: 'Subtitle generation modal did not acknowledge opening; retrying.',
sendOpen: () =>
openOverlayHostedModal(deps, {
channel: IPC_CHANNELS.event.subtitleGenerationOpen,
modal: 'subtitle-generation',
preferModalWindow: true,
}),
});
}
@@ -0,0 +1,259 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../shared/subtitle-generation';
import {
createSubtitleGenerationRuntime,
type SubtitleGenerationRuntimeDeps,
} from './subtitle-generation-runtime';
function fixture(overrides: Partial<SubtitleGenerationRuntimeDeps> = {}) {
let mediaPath = '/video/episode.mkv';
const commands: unknown[][] = [];
const client = {
connected: true,
requestProperty: async (name: string): Promise<unknown> =>
name === 'path' ? mediaPath : [{ type: 'audio', selected: true, 'ff-index': 3 }],
request: async (command: unknown[]) => {
commands.push(command);
return { error: 'success' };
},
};
const runtime = createSubtitleGenerationRuntime({
getConfig: () => DEFAULT_SUBTITLE_GENERATION_CONFIG,
getModelDirectory: () => '/models',
getMpvClient: () => client,
onProgress: () => {},
resolveModel: async () => ({ kind: 'external', path: '/models/local.bin' }),
resolveTools: async (config) => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' },
whisper: { kind: 'found', path: '/usr/bin/whisper-cli' },
vad: config.vadModelPath ? { kind: 'found', path: '/usr/bin/vad' } : null,
}),
generate: async () => '/video/episode.ja.generated.srt',
...overrides,
});
return {
runtime,
client,
commands,
changeMedia: () => {
mediaPath = '/video/next.mkv';
},
};
}
test('generation uses the selected audio track and loads the timed SRT with zero delay', async () => {
const { runtime, commands } = fixture({
generate: async (input) => {
assert.equal(input.mediaPath, '/video/episode.mkv');
assert.equal(input.audioStreamIndex, 3);
return '/video/generated.srt';
},
});
assert.equal((await runtime.start()).ok, true);
assert.deepEqual(commands, [
['sub-add', '/video/generated.srt', 'select', 'Generated Japanese', 'ja'],
['set_property', 'sub-delay', 0],
]);
});
test('generation preserves the output without attaching it to a different video', async () => {
const subject = fixture({
generate: async () => {
subject.changeMedia();
return '/video/generated.srt';
},
});
const result = await subject.runtime.start();
assert.equal(result.ok, true);
assert.match(result.message, /Playback changed/);
assert.deepEqual(subject.commands, []);
});
test('mpv load failure still reports where the generated subtitles were saved', async () => {
const subject = fixture();
subject.client.request = async () => ({ error: 'loading failed' });
const result = await subject.runtime.start();
assert.equal(result.ok, true);
assert.match(result.message, /Subtitles saved:.*Could not finish loading/);
});
test('cancellation during the final media check keeps the saved file without loading it', async () => {
const subject = fixture();
const requestProperty = subject.client.requestProperty;
let mediaChecks = 0;
subject.client.requestProperty = async (name) => {
if (name === 'path' && ++mediaChecks === 3) subject.runtime.cancel();
return requestProperty(name);
};
const result = await subject.runtime.start();
assert.equal(result.ok, true);
assert.match(result.message, /Cancelled after saving/);
assert.deepEqual(subject.commands, []);
});
test('only one job runs, cancellation reaches the worker, and status retains its result', async () => {
let signal: AbortSignal | undefined;
let entered = () => {};
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const { runtime } = fixture({
generate: async (input) => {
signal = input.signal;
input.onProgress?.({ stage: 'transcribe', percent: 25, message: 'Working' });
entered();
return new Promise((_, reject) =>
input.signal?.addEventListener('abort', () => reject(new Error('Aborted')), { once: true }),
);
},
});
const first = runtime.start();
await started;
await assert.rejects(runtime.selectModel('medium'), /current operation/);
assert.equal((await runtime.start()).ok, false);
assert.equal((await runtime.download()).ok, false);
const active = await runtime.getStatus();
assert.equal(active.running, true);
assert.equal(active.progress?.percent, 25);
runtime.cancel();
assert.equal(signal?.aborted, true);
assert.deepEqual(await first, { ok: false, message: 'Cancelled.' });
const completed = await runtime.getStatus();
assert.equal(completed.running, false);
assert.deepEqual(completed.lastResult, { ok: false, message: 'Cancelled.' });
});
test('external audio cannot silently generate from a different internal track', async () => {
const subject = fixture({
generate: async () => {
assert.fail('must not transcribe');
},
});
subject.client.requestProperty = async (name) =>
name === 'path'
? '/video/episode.mkv'
: [{ type: 'audio', selected: true, external: true, 'ff-index': 0 }];
const result = await subject.runtime.start();
assert.equal(result.ok, false);
assert.match(result.message, /audio track inside/);
});
test('model selection is retained and used for status, download, and generation', async () => {
const seen: string[] = [];
const { runtime } = fixture({
resolveModel: async (config) => ({
kind: 'missing',
path: `/models/${config.managedModel}.bin`,
}),
download: async ({ config }) => {
seen.push(`download:${config.managedModel}`);
return '/models/downloaded.bin';
},
generate: async ({ config }) => {
seen.push(`generate:${config.managedModel}`);
return '/video/generated.srt';
},
});
const selected = await runtime.selectModel('medium');
assert.equal(selected.managedModel, 'medium');
assert.equal(selected.model.path, '/models/medium.bin');
assert.equal((await runtime.getStatus()).managedModel, 'medium');
assert.equal((await runtime.download()).ok, true);
assert.equal((await runtime.start()).ok, true);
assert.deepEqual(seen, ['download:medium', 'generate:medium']);
assert.equal(DEFAULT_SUBTITLE_GENERATION_CONFIG.managedModel, 'small');
});
test('external model paths prevent managed selection, including unreadable overrides', async () => {
const { runtime } = fixture({
getConfig: () => ({
...DEFAULT_SUBTITLE_GENERATION_CONFIG,
modelPath: '/missing/external.bin',
}),
resolveModel: async () => ({
kind: 'invalid',
path: '/missing/external.bin',
message: 'Missing model',
}),
});
assert.equal((await runtime.getStatus()).externalModelPath, '/missing/external.bin');
await assert.rejects(runtime.selectModel('medium'), /Clear Model Path/);
});
test('status reports the speech detector only while dialogue mode is on', async () => {
const { runtime } = fixture({
resolveVadModel: async () => ({ kind: 'managed', path: '/models/ggml-silero-v6.2.0.bin' }),
});
assert.equal((await runtime.getStatus()).tools.vad, null);
await runtime.setVadEnabled(true);
assert.deepEqual((await runtime.getStatus()).tools.vad, { kind: 'found', path: '/usr/bin/vad' });
});
test('speech detection is optional and downloading alone does not enable it', async () => {
let installed = false;
const paths: string[] = [];
const { runtime } = fixture({
resolveVadModel: async () => ({
kind: installed ? 'managed' : 'missing',
path: '/models/ggml-silero-v6.2.0.bin',
}),
downloadVad: async () => {
installed = true;
return '/models/ggml-silero-v6.2.0.bin';
},
generate: async ({ config }) => {
paths.push(config.vadModelPath);
return '/video/output.srt';
},
});
assert.equal((await runtime.getStatus()).vad.enabled, false);
assert.equal((await runtime.start()).ok, true);
await runtime.setVadEnabled(true);
assert.equal((await runtime.start()).ok, false);
await runtime.setVadEnabled(false);
assert.equal((await runtime.downloadVad()).ok, true);
assert.equal((await runtime.getStatus()).vad.enabled, false);
await runtime.setVadEnabled(true);
assert.equal((await runtime.start()).ok, true);
await runtime.setVadEnabled(false);
assert.equal((await runtime.start()).ok, true);
assert.deepEqual(paths, ['', '/models/ggml-silero-v6.2.0.bin', '']);
});
test('existing external speech model remains the default and survives session toggles', async () => {
const { runtime } = fixture({
getConfig: () => ({ ...DEFAULT_SUBTITLE_GENERATION_CONFIG, vadModelPath: '/external/vad.bin' }),
resolveVadModel: async (config) => ({ kind: 'external', path: config.vadModelPath }),
generate: async ({ config }) => {
assert.equal(config.vadModelPath, '/external/vad.bin');
return '/video/output.srt';
},
});
assert.equal((await runtime.getStatus()).vad.enabled, true);
await runtime.setVadEnabled(false);
await runtime.setVadEnabled(true);
assert.equal((await runtime.start()).ok, true);
});
test('speech model downloads share the job lock and cancellation', async () => {
let enter = () => {};
const started = new Promise<void>((resolve) => {
enter = resolve;
});
const { runtime } = fixture({
downloadVad: async ({ signal }) => {
enter();
return new Promise((_, reject) =>
signal?.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }),
);
},
});
const download = runtime.downloadVad();
await started;
assert.equal((await runtime.start()).ok, false);
await assert.rejects(runtime.setVadEnabled(true), /current operation/);
runtime.cancel();
assert.deepEqual(await download, { ok: false, message: 'Cancelled.' });
});
@@ -0,0 +1,264 @@
import path from 'node:path';
import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model';
import {
downloadSubtitleGenerationVadModel,
resolveSubtitleGenerationVadModel,
} from '../../core/services/subtitle-generation-vad-model';
import type { SubtitleGenerationModelId } from '../../shared/subtitle-generation-model-catalog';
import {
downloadSubtitleGenerationModel,
generateJapaneseSubtitles,
resolveSubtitleGenerationModel,
resolveSubtitleGenerationTools,
} from '../../core/services/subtitle-generation';
import type {
SubtitleGenerationConfig,
SubtitleGenerationProgress,
} from '../../shared/subtitle-generation';
import type {
SubtitleGenerationResult,
SubtitleGenerationStatus,
} from '../../shared/subtitle-generation-ipc';
interface GenerationMpvClient {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
request: (command: unknown[]) => Promise<{ error?: string }>;
}
export interface SubtitleGenerationRuntimeDeps {
getConfig: () => SubtitleGenerationConfig;
getModelDirectory: () => string;
getMpvClient: () => GenerationMpvClient | null;
onProgress: (progress: SubtitleGenerationProgress) => void;
generate?: typeof generateJapaneseSubtitles;
download?: typeof downloadSubtitleGenerationModel;
resolveModel?: typeof resolveSubtitleGenerationModel;
resolveTools?: typeof resolveSubtitleGenerationTools;
downloadVad?: typeof downloadSubtitleGenerationVadModel;
resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
}
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
if (typeof media !== 'string' || !media || /^[a-z][a-z\d+.-]*:\/\//i.test(media)) return null;
if (path.isAbsolute(media)) return path.normalize(media);
const directory = await client.requestProperty('working-directory');
return typeof directory === 'string' ? path.resolve(directory, media) : null;
}
function selectedAudioIndex(tracks: unknown): number {
if (!Array.isArray(tracks)) throw new Error('Unable to inspect the selected audio track.');
for (const track of tracks) {
if (
!track ||
typeof track !== 'object' ||
!('type' in track) ||
track.type !== 'audio' ||
!('selected' in track) ||
track.selected !== true
)
continue;
if ('external' in track && track.external === true)
throw new Error('Select an audio track inside the local video before generating subtitles.');
if (
'ff-index' in track &&
typeof track['ff-index'] === 'number' &&
Number.isInteger(track['ff-index']) &&
track['ff-index'] >= 0
)
return track['ff-index'];
throw new Error('The selected audio track has no FFmpeg stream index.');
}
throw new Error('Select an audio track in mpv before generating subtitles.');
}
export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeDeps) {
let controller: AbortController | null = null;
let progress: SubtitleGenerationProgress | null = null;
let lastResult: SubtitleGenerationResult | null = null;
let selectedModel: SubtitleGenerationModelId | null = null;
let vadEnabled: boolean | null = null;
function getConfig(): SubtitleGenerationConfig {
const config = deps.getConfig();
return {
...config,
managedModel: selectedModel ?? config.managedModel,
vadModelPath:
vadEnabled === null
? config.vadModelPath
: vadEnabled
? config.vadModelPath.trim() ||
path.resolve(deps.getModelDirectory(), SUBTITLE_GENERATION_VAD_MODEL.filename)
: '',
};
}
const report = (update: SubtitleGenerationProgress) => {
progress = update;
deps.onProgress(update);
};
async function run(
operation: (signal: AbortSignal) => Promise<SubtitleGenerationResult>,
): Promise<SubtitleGenerationResult> {
if (controller)
return { ok: false, message: 'A subtitle generation or model download is already running.' };
const active = new AbortController();
controller = active;
progress = null;
lastResult = null;
try {
lastResult = await operation(active.signal);
} catch (error) {
lastResult = {
ok: false,
message: active.signal.aborted
? 'Cancelled.'
: error instanceof Error
? error.message
: String(error),
};
} finally {
controller = null;
}
return lastResult;
}
async function getStatus(): Promise<SubtitleGenerationStatus> {
const config = getConfig();
const model = await (deps.resolveModel ?? resolveSubtitleGenerationModel)(
config,
deps.getModelDirectory(),
);
const mediaPath = await currentLocalMedia(deps.getMpvClient()).catch(() => null);
return {
model,
vad: {
enabled: Boolean(config.vadModelPath.trim()),
model: await (deps.resolveVadModel ?? resolveSubtitleGenerationVadModel)(
deps.getConfig(),
deps.getModelDirectory(),
),
},
// Session toggles decide whether the speech detector executable is required.
tools: await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config),
managedModel: config.managedModel,
externalModelPath: config.modelPath.trim() || null,
mediaPath,
running: controller !== null,
progress,
lastResult,
};
}
return {
getStatus,
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
if (controller)
throw new Error('Wait for the current operation before changing speech detection.');
vadEnabled = enabled;
lastResult = null;
progress = null;
return getStatus();
},
downloadVad(): Promise<SubtitleGenerationResult> {
return run(async (signal) => {
await (deps.downloadVad ?? downloadSubtitleGenerationVadModel)({
config: deps.getConfig(),
modelDirectory: deps.getModelDirectory(),
onProgress: report,
signal,
});
return { ok: true, message: 'Speech detection model is ready.' };
});
},
async selectModel(model: SubtitleGenerationModelId): Promise<SubtitleGenerationStatus> {
if (controller) throw new Error('Wait for the current operation before changing models.');
if (deps.getConfig().modelPath.trim())
throw new Error('Clear Model Path in Settings before choosing a managed model.');
selectedModel = model;
lastResult = null;
progress = null;
return getStatus();
},
cancel(): void {
controller?.abort();
},
download(): Promise<SubtitleGenerationResult> {
return run(async (signal) => {
await (deps.download ?? downloadSubtitleGenerationModel)({
config: getConfig(),
modelDirectory: deps.getModelDirectory(),
onProgress: report,
signal,
});
return { ok: true, message: 'Model downloaded. Ready to generate Japanese subtitles.' };
});
},
start(): Promise<SubtitleGenerationResult> {
return run(async (signal) => {
const config = getConfig();
if (config.vadModelPath.trim()) {
const vad = await (deps.resolveVadModel ?? resolveSubtitleGenerationVadModel)(
deps.getConfig(),
deps.getModelDirectory(),
);
if (vad.kind === 'missing')
throw new Error(
'Download the optional speech detection model or turn off Focus on spoken dialogue.',
);
if (vad.kind === 'invalid') throw new Error(vad.message);
}
const client = deps.getMpvClient();
const mediaPath = await currentLocalMedia(client);
if (!client || !mediaPath)
throw new Error('Open a local video or audio file in mpv first.');
const audioStreamIndex = selectedAudioIndex(await client.requestProperty('track-list'));
if ((await currentLocalMedia(client)) !== mediaPath)
throw new Error('The current media changed. Start generation again.');
signal.throwIfAborted();
const outputPath = await (deps.generate ?? generateJapaneseSubtitles)({
config,
modelDirectory: deps.getModelDirectory(),
mediaPath,
audioStreamIndex,
onProgress: report,
signal,
});
// Saving succeeds even if playback changes or disconnects during the job.
try {
const playingMedia = await currentLocalMedia(client);
if (!signal.aborted && deps.getMpvClient() === client && playingMedia === mediaPath) {
const loaded = await client.request([
'sub-add',
outputPath,
'select',
'Generated Japanese',
'ja',
]);
if (loaded.error && loaded.error !== 'success') throw new Error(loaded.error);
const delay = await client.request(['set_property', 'sub-delay', 0]);
if (delay.error && delay.error !== 'success') throw new Error(delay.error);
return {
ok: true,
outputPath,
message: `Japanese subtitles saved and loaded: ${outputPath}`,
};
}
return {
ok: true,
outputPath,
message: `Subtitles saved: ${outputPath}. ${signal.aborted ? 'Cancelled after saving; the file was not loaded.' : 'Playback changed, so the file was not loaded.'}`,
};
} catch (error) {
return {
ok: true,
outputPath,
message: `Subtitles saved: ${outputPath}. Could not finish loading into mpv: ${error instanceof Error ? error.message : String(error)}`,
};
}
});
},
};
}
@@ -58,7 +58,7 @@ test('updateAppImageFromRelease verifies hash and atomically replaces writable A
]);
});
test('updateAppImageFromRelease reports protected command without replacing non-writable AppImage', async () => {
test('updateAppImageFromRelease reports protected command for a direct non-writable AppImage', async () => {
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
@@ -67,7 +67,7 @@ test('updateAppImageFromRelease reports protected command without replacing non-
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
appImagePath: '/opt/SubMiner/SubMiner.AppImage',
appImagePath: '/usr/local/lib/SubMiner.AppImage',
downloadAsset: async () => appImageBytes,
fs: {
stat: async () => ({
@@ -87,10 +87,50 @@ test('updateAppImageFromRelease reports protected command without replacing non-
});
assert.equal(result.status, 'protected');
assert.equal(result.path, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(result.path, '/usr/local/lib/SubMiner.AppImage');
assert.match(result.command ?? '', /curl -fSL 'https:\/\/example\.test\/app' -o "\$tmp"/);
assert.match(result.command ?? '', /sha256sum -c -/);
assert.match(result.command ?? '', /sudo mv "\$tmp" '\/opt\/SubMiner\/SubMiner\.AppImage'/);
assert.match(result.command ?? '', /sudo mv "\$tmp" '\/usr\/local\/lib\/SubMiner\.AppImage'/);
});
test('updateAppImageFromRelease leaves canonical and symlinked AUR AppImages to pacman', async () => {
for (const appImagePath of ['/opt/SubMiner/SubMiner.AppImage', '/usr/bin/SubMiner.AppImage']) {
let accessed = false;
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
appImagePath,
downloadAsset: async () => {
throw new Error('must not download package-managed AppImage');
},
fs: {
realpath: async () => '/opt/SubMiner/SubMiner.AppImage',
stat: async () => {
throw new Error('must not stat package-managed AppImage');
},
access: async () => {
accessed = true;
},
writeFile: async () => {},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.deepEqual(result, {
status: 'skipped',
path: appImagePath,
message: 'This AppImage is managed by the subminer-bin system package.',
});
assert.equal(accessed, false);
assert.equal(result.command, undefined);
}
});
test('buildProtectedAppImageUpdateCommand quotes inputs and verifies checksum before sudo move', () => {
@@ -25,6 +25,7 @@ export interface AppImageUpdateResult {
}
export interface AppImageUpdateFileSystem {
realpath?: (targetPath: string) => Promise<string>;
stat: (targetPath: string) => Promise<StatLike>;
access: (targetPath: string) => Promise<void>;
writeFile: (targetPath: string, data: Buffer) => Promise<void>;
@@ -39,6 +40,7 @@ function sha256(data: Buffer): string {
function defaultFs(): AppImageUpdateFileSystem {
return {
realpath: (targetPath) => fs.promises.realpath(targetPath),
stat: (targetPath) => fs.promises.stat(targetPath),
access: async (targetPath) => {
await fs.promises.access(targetPath, fs.constants.W_OK);
@@ -105,6 +107,19 @@ export async function updateAppImageFromRelease(options: {
}
const fsDeps = options.fs ?? defaultFs();
let resolvedAppImagePath = options.appImagePath;
try {
resolvedAppImagePath = (await fsDeps.realpath?.(options.appImagePath)) ?? options.appImagePath;
} catch {
// stat below reports a missing or inaccessible path with the existing result shape.
}
if (resolvedAppImagePath === '/opt/SubMiner/SubMiner.AppImage') {
return {
status: 'skipped',
path: options.appImagePath,
message: 'This AppImage is managed by the subminer-bin system package.',
};
}
let stat: StatLike;
try {
stat = await fsDeps.stat(options.appImagePath);
@@ -5,6 +5,7 @@ import {
buildProtectedLauncherUpdateCommand,
looksLikeSubminerLauncher,
updateLauncherAtPath,
updateLauncherFromRelease,
} from './launcher-updater';
const launcherBytes = Buffer.from('#!/usr/bin/env bash\n# SubMiner launcher\nexec SubMiner "$@"\n');
@@ -124,3 +125,137 @@ test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher conte
assert.equal(suspicious.status, 'skipped');
assert.equal(mismatch.status, 'hash-mismatch');
});
test('app-managed wrappers are never overwritten by the standalone release script', async () => {
const { managedLauncherContent } = await import('../managed-launcher');
let downloaded = false;
const result = await updateLauncherAtPath({
launcherPath: '/home/tester/.local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
download: async () => {
downloaded = true;
return launcherBytes;
},
fs: {
stat: async () => ({ isFile: () => true }),
readFile: async () =>
managedLauncherContent({
platform: 'linux',
appPath: '/apps/SubMiner.AppImage',
}),
access: async () => {
throw new Error('must not modify wrapper');
},
writeFile: async () => {
throw new Error('must not modify wrapper');
},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.equal(result.status, 'skipped');
assert.equal(downloaded, false);
});
test('GUI updates defer recognized standalone launcher migration to app startup', async () => {
const accessed: string[] = [];
let downloaded = false;
const result = await updateLauncherAtPath({
launcherPath: '/home/tester/.local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
deferRecognizedLauncherUpdate: true,
download: async () => {
downloaded = true;
return launcherBytes;
},
fs: {
stat: async () => ({ isFile: () => true }),
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
access: async (targetPath) => {
accessed.push(targetPath);
},
writeFile: async () => {},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.deepEqual(result, {
status: 'skipped',
path: '/home/tester/.local/bin/subminer',
message: 'Launcher migration is deferred until the updated SubMiner app starts.',
deferred: true,
});
assert.deepEqual(accessed, ['/home/tester/.local/bin/subminer', '/home/tester/.local/bin']);
assert.equal(downloaded, false);
});
test('release launcher updater propagates GUI migration deferral', async () => {
let downloaded = false;
const result = await updateLauncherFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'subminer', browser_download_url: 'https://example.test/subminer' }],
},
sha256Sums: new Map([['subminer', launcherHash]]),
launcherPath: '/home/tester/.local/bin/subminer',
deferRecognizedLauncherUpdate: true,
exists: () => true,
downloadAsset: async () => {
downloaded = true;
return launcherBytes;
},
fs: {
stat: async () => ({ isFile: () => true }),
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
access: async () => {},
writeFile: async () => {},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.equal(result.status, 'skipped');
assert.match(result.message ?? '', /deferred until the updated SubMiner app starts/);
assert.equal(downloaded, false);
});
test('GUI migration reports a protected launcher when its file or parent is not writable', async () => {
const launcherPath = '/usr/local/bin/subminer';
for (const protectedPath of [launcherPath, '/usr/local/bin']) {
const result = await updateLauncherAtPath({
launcherPath,
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
deferRecognizedLauncherUpdate: true,
download: async () => {
throw new Error('Protected launchers must not download a replacement.');
},
fs: {
stat: async () => ({ isFile: () => true }),
readFile: async () => Buffer.from('#!/usr/bin/env bun\n// SubMiner launcher\n'),
access: async (targetPath) => {
if (targetPath === protectedPath) throw new Error('EACCES');
},
writeFile: async () => {
throw new Error('Protected launchers must not be written.');
},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.equal(result.status, 'protected', protectedPath);
assert.equal(
result.command,
buildProtectedLauncherUpdateCommand('https://example.test/subminer', launcherPath),
);
}
});
+25 -1
View File
@@ -4,6 +4,7 @@ import os from 'node:os';
import path from 'node:path';
import type { GitHubRelease } from './release-assets';
import { findReleaseAsset } from './release-assets';
import { isManagedLauncher } from '../managed-launcher';
type StatLike = {
isFile: () => boolean;
@@ -23,6 +24,8 @@ export interface LauncherUpdateResult {
path?: string;
command?: string;
message?: string;
// Set when a writable legacy launcher was left for app startup to migrate.
deferred?: boolean;
}
export interface LauncherUpdateFileSystem {
@@ -82,6 +85,7 @@ export async function updateLauncherAtPath(options: {
assetUrl: string;
expectedSha256: string;
download: () => Promise<Buffer>;
deferRecognizedLauncherUpdate?: boolean;
fs?: LauncherUpdateFileSystem;
}): Promise<LauncherUpdateResult> {
const fsDeps = options.fs ?? defaultFs();
@@ -96,6 +100,13 @@ export async function updateLauncherAtPath(options: {
}
const existing = await fsDeps.readFile(options.launcherPath);
if (isManagedLauncher(existing.toString())) {
return {
status: 'skipped',
path: options.launcherPath,
message: 'This launcher is updated with the SubMiner app.',
};
}
if (!looksLikeSubminerLauncher(existing)) {
return {
status: 'skipped',
@@ -103,9 +114,9 @@ export async function updateLauncherAtPath(options: {
message: 'Existing executable does not look like a SubMiner launcher.',
};
}
try {
await fsDeps.access(options.launcherPath);
await fsDeps.access(path.dirname(options.launcherPath));
} catch {
return {
status: 'protected',
@@ -114,6 +125,15 @@ export async function updateLauncherAtPath(options: {
};
}
if (options.deferRecognizedLauncherUpdate) {
return {
status: 'skipped',
path: options.launcherPath,
message: 'Launcher migration is deferred until the updated SubMiner app starts.',
deferred: true,
};
}
const data = await options.download();
const actualSha256 = sha256(data);
if (actualSha256 !== options.expectedSha256.toLowerCase()) {
@@ -160,7 +180,9 @@ export async function updateLauncherFromRelease(options: {
platform?: NodeJS.Platform;
homeDir?: string;
downloadAsset: (url: string) => Promise<Buffer>;
deferRecognizedLauncherUpdate?: boolean;
exists?: (targetPath: string) => boolean;
fs?: LauncherUpdateFileSystem;
}): Promise<LauncherUpdateResult> {
if (!options.release) return { status: 'missing-asset', message: 'No release found.' };
const asset = findReleaseAsset(options.release, 'subminer');
@@ -184,5 +206,7 @@ export async function updateLauncherFromRelease(options: {
assetUrl: asset.browser_download_url,
expectedSha256,
download: () => options.downloadAsset(asset.browser_download_url),
deferRecognizedLauncherUpdate: options.deferRecognizedLauncherUpdate,
fs: options.fs,
});
}
@@ -19,7 +19,11 @@ import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy
import { updateLauncherFromRelease } from './launcher-updater';
import { notifyUpdateAvailable } from './update-notifications';
import { createUpdateDialogPresenter } from './update-dialogs';
import { createFileUpdateStateStore, createUpdateService } from './update-service';
import {
createFileUpdateStateStore,
createUpdateService,
takePendingLauncherMigrationPath,
} from './update-service';
import { updateSupportAssetsFromRelease } from './support-assets';
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
@@ -38,6 +42,9 @@ export interface UpdateServiceRuntimeDeps {
export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
getUpdateService: () => ReturnType<typeof createUpdateService>;
takePendingLauncherMigrationPath: (
refresh: Parameters<typeof takePendingLauncherMigrationPath>[1],
) => Promise<string | undefined>;
} {
const updateStateStore = createFileUpdateStateStore(
path.join(deps.userDataPath, 'update-state.json'),
@@ -79,6 +86,7 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
sha256Sums: sums,
launcherPath,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
deferRecognizedLauncherUpdate: true,
});
return runSupportAssetUpdatesForLauncherResult({
launcherResult,
@@ -152,8 +160,7 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
getConfig: () => deps.getUpdatesConfig(),
getCurrentVersion: () => app.getVersion(),
now: () => Date.now(),
readState: () => updateStateStore.readState(),
writeState: (state) => updateStateStore.writeState(state),
stateStore: updateStateStore,
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
shouldFetchReleaseMetadata: ({ request, appUpdate }) =>
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request),
@@ -187,5 +194,9 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
return updateService;
}
return { getUpdateService };
return {
getUpdateService,
takePendingLauncherMigrationPath: (refresh) =>
takePendingLauncherMigrationPath(updateStateStore, refresh),
};
}
+109 -13
View File
@@ -1,7 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
import { createUpdateService, type UpdateServiceDeps, type UpdateState } from './update-service';
import {
createUpdateService,
createUpdateStateStore,
takePendingLauncherMigrationPath,
type UpdateServiceDeps,
type UpdateState,
} from './update-service';
function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
let state: UpdateState = {};
@@ -15,11 +21,13 @@ function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
}),
getCurrentVersion: () => '0.14.0',
now: () => 1_000_000,
readState: async () => state,
writeState: async (nextState) => {
state = nextState;
calls.push(`state:${JSON.stringify(nextState)}`);
},
stateStore: createUpdateStateStore({
readState: async () => state,
writeState: async (nextState) => {
state = nextState;
calls.push(`state:${JSON.stringify(nextState)}`);
},
}),
checkAppUpdate: async () => ({ available: false, version: '0.14.0' }),
fetchLatestStableRelease: async () => ({
tag_name: 'v0.14.0',
@@ -286,7 +294,7 @@ test('concurrent update checks share one in-flight check', async () => {
const first = service.checkForUpdates({ source: 'manual' });
const second = service.checkForUpdates({ source: 'manual' });
await Promise.resolve();
await new Promise<void>((resolve) => setImmediate(resolve));
resolveCheck({ available: false, version: '0.14.0' });
await Promise.all([first, second]);
@@ -307,7 +315,7 @@ test('manual install request does not reuse in-flight manual check', async () =>
const manualCheck = service.checkForUpdates({ source: 'manual' });
const manualInstall = service.checkForUpdates({ source: 'manual', installWhenAvailable: true });
await Promise.resolve();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(checkCount, 2);
for (const resolve of resolveChecks) {
resolve({ available: false, version: '0.14.0' });
@@ -315,26 +323,32 @@ test('manual install request does not reuse in-flight manual check', async () =>
await Promise.all([manualCheck, manualInstall]);
});
test('manual update check does not reuse in-flight automatic check', async () => {
test('manual update check does not reuse in-flight automatic check and preserves all state', async () => {
let checkCount = 0;
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
const { deps } = createDeps({
const { deps, getState } = createDeps({
checkAppUpdate: () =>
new Promise((resolve) => {
checkCount += 1;
resolveChecks.push(resolve);
}),
updateLauncher: async () => ({ status: 'skipped', path: '/x/subminer', deferred: true }),
});
const service = createUpdateService(deps);
const automatic = service.checkForUpdates({ source: 'automatic', force: true });
const manual = service.checkForUpdates({ source: 'manual' });
const manual = service.checkForUpdates({ source: 'manual', installWhenAvailable: true });
await Promise.resolve();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(checkCount, 2);
for (const resolve of resolveChecks) {
resolve({ available: false, version: '0.14.0' });
resolve({ available: true, version: '0.15.0' });
}
await Promise.all([automatic, manual]);
assert.deepEqual(getState(), {
pendingLauncherMigrationPath: '/x/subminer',
lastAutomaticCheckAt: 1_000_000,
lastNotifiedVersion: '0.15.0',
});
});
test('manual update check passes selected GitHub release to launcher update', async () => {
@@ -488,3 +502,85 @@ test('manual update check keeps current prerelease builds on configured stable c
assert.equal(result.status, 'up-to-date');
assert.deepEqual(calls, ['app:stable', 'fetch:stable', 'no-update:0.15.0-beta.3']);
});
test('deferred launcher migration is persisted for the next app start', async () => {
const launcherPath = '/home/tester/.local/bin/subminer';
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
fetchLatestStableRelease: async () => ({
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [],
}),
showUpdateAvailableDialog: async () => 'update',
updateLauncher: async () => ({ status: 'skipped', path: launcherPath, deferred: true }),
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual', launcherPath });
assert.equal(result.status, 'updated');
assert.ok(
calls.includes(`state:${JSON.stringify({ pendingLauncherMigrationPath: launcherPath })}`),
);
});
test('takePendingLauncherMigrationPath hands the path over exactly once', async () => {
let state: UpdateState = {
lastNotifiedVersion: '0.15.0',
pendingLauncherMigrationPath: '/x/subminer',
};
const store = createUpdateStateStore({
readState: async () => state,
writeState: async (nextState: UpdateState) => {
state = nextState;
},
});
const refresh = async () => true;
assert.deepEqual(
await Promise.all([
takePendingLauncherMigrationPath(store, refresh),
takePendingLauncherMigrationPath(store, refresh),
]),
['/x/subminer', undefined],
);
assert.deepEqual(state, { lastNotifiedVersion: '0.15.0' });
assert.equal(await takePendingLauncherMigrationPath(store, refresh), undefined);
});
test('failed migration remains pending and acknowledgement preserves concurrent check state', async () => {
const { deps, getState, setState } = createDeps({
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
});
setState({ pendingLauncherMigrationPath: '/x/subminer' });
await assert.rejects(
takePendingLauncherMigrationPath(deps.stateStore, async () => {
throw new Error('refresh failed');
}),
/refresh failed/,
);
assert.equal(getState().pendingLauncherMigrationPath, '/x/subminer');
const service = createUpdateService(deps);
const check = service.checkForUpdates({ source: 'automatic' });
await takePendingLauncherMigrationPath(deps.stateStore, async () => false);
await check;
assert.deepEqual(getState(), {
pendingLauncherMigrationPath: '/x/subminer',
lastAutomaticCheckAt: 1_000_000,
lastNotifiedVersion: '0.15.0',
});
const nextCheck = service.checkForUpdates({ source: 'automatic', force: true });
assert.equal(
await takePendingLauncherMigrationPath(deps.stateStore, async () => true),
'/x/subminer',
);
await nextCheck;
assert.deepEqual(getState(), {
lastAutomaticCheckAt: 1_000_000,
lastNotifiedVersion: '0.15.0',
});
});
+56 -16
View File
@@ -7,6 +7,8 @@ import { compareSemverLike, parseReleaseVersion } from './release-assets';
export interface UpdateState {
lastAutomaticCheckAt?: number;
lastNotifiedVersion?: string;
// Legacy launcher the last update left for the next app start to migrate.
pendingLauncherMigrationPath?: string;
}
export type UpdateCheckSource = 'manual' | 'automatic' | 'launcher';
@@ -41,8 +43,7 @@ export interface UpdateServiceDeps {
getConfig: () => Required<UpdatesConfig>;
getCurrentVersion: () => string;
now: () => number;
readState: () => Promise<UpdateState>;
writeState: (state: UpdateState) => Promise<void>;
stateStore: ReturnType<typeof createUpdateStateStore>;
checkAppUpdate: (channel: UpdateChannel) => Promise<AppUpdateMetadata>;
shouldFetchReleaseMetadata?: (input: {
request: UpdateCheckRequest;
@@ -54,7 +55,7 @@ export interface UpdateServiceDeps {
launcherPath?: string,
channel?: UpdateChannel,
release?: GitHubRelease | null,
) => Promise<{ status: string; command?: string }>;
) => Promise<{ status: string; command?: string; path?: string; deferred?: boolean }>;
showNoUpdateDialog: (version: string) => Promise<void>;
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
showUpdateFailedDialog: (message: string) => Promise<void>;
@@ -121,7 +122,7 @@ export function createUpdateService(deps: UpdateServiceDeps) {
const now = deps.now();
const config = deps.getConfig();
const channel = config.channel;
const state = await deps.readState();
const state = await deps.stateStore.transaction((store) => store.readState());
const isAutomatic = request.source === 'automatic';
if (isAutomatic && !request.force && shouldSkipAutomaticCheck(config, state, now)) {
@@ -150,15 +151,18 @@ export function createUpdateService(deps: UpdateServiceDeps) {
const latest = getBestLatestVersion(currentVersion, appUpdate, release);
if (isAutomatic) {
const nextState: UpdateState = {
...state,
lastAutomaticCheckAt: now,
};
if (latest.available && state.lastNotifiedVersion !== latest.version) {
await deps.notifyUpdateAvailable(latest.version);
nextState.lastNotifiedVersion = latest.version;
}
await deps.writeState(nextState);
await deps.stateStore.transaction(async (store) => {
const currentState = await store.readState();
const nextState: UpdateState = {
...currentState,
lastAutomaticCheckAt: now,
};
if (latest.available && currentState.lastNotifiedVersion !== latest.version) {
await deps.notifyUpdateAvailable(latest.version);
nextState.lastNotifiedVersion = latest.version;
}
await store.writeState(nextState);
});
}
if (!latest.available) {
@@ -189,6 +193,12 @@ export function createUpdateService(deps: UpdateServiceDeps) {
if (launcherResult.status === 'protected' && launcherResult.command) {
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
}
if (launcherResult.deferred && launcherResult.path) {
const pendingLauncherMigrationPath = launcherResult.path;
await deps.stateStore.transaction(async (store) => {
await store.writeState({ ...(await store.readState()), pendingLauncherMigrationPath });
});
}
if (!appUpdateApplied) {
await deps.showManualUpdateRequiredDialog(latest.version);
@@ -237,11 +247,41 @@ export function createUpdateService(deps: UpdateServiceDeps) {
};
}
export function createFileUpdateStateStore(statePath: string): {
// Keep the path until refresh acknowledges migration or an ineligible candidate.
export async function takePendingLauncherMigrationPath(
stateStore: ReturnType<typeof createUpdateStateStore>,
refresh: (launcherPath: string | undefined) => Promise<boolean>,
): Promise<string | undefined> {
return stateStore.transaction(async (store) => {
const { pendingLauncherMigrationPath, ...rest } = await store.readState();
const acknowledged = await refresh(pendingLauncherMigrationPath);
if (!pendingLauncherMigrationPath || !acknowledged) {
return undefined;
}
await store.writeState(rest);
return pendingLauncherMigrationPath;
});
}
export function createUpdateStateStore(store: {
readState: () => Promise<UpdateState>;
writeState: (state: UpdateState) => Promise<void>;
} {
}) {
let pending = Promise.resolve();
return {
transaction<T>(operation: (stateStore: typeof store) => Promise<T>): Promise<T> {
const result = pending.then(() => operation(store));
pending = result.then(
() => {},
() => {},
);
return result;
},
};
}
export function createFileUpdateStateStore(statePath: string) {
return createUpdateStateStore({
async readState(): Promise<UpdateState> {
try {
return JSON.parse(await fs.promises.readFile(statePath, 'utf8')) as UpdateState;
@@ -253,5 +293,5 @@ export function createFileUpdateStateStore(statePath: string): {
await fs.promises.mkdir(path.dirname(statePath), { recursive: true });
await fs.promises.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
},
};
});
}
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { getRunCommand } from './command-line-launcher-deps';
import { windowsLauncherBootstrapContent } from './windows-launcher-bootstrap';
function workspace(t: test.TestContext): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer windows bootstrap '));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
test('generic bootstrap searches supported Windows install locations in order', () => {
const content = windowsLauncherBootstrapContent();
const override = content.indexOf('if defined SUBMINER_BINARY_PATH goto subminer_app_found');
const localInstall = content.indexOf('%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe');
const machineInstall = content.indexOf('%ProgramFiles%\\SubMiner\\SubMiner.exe');
assert.ok(override >= 0);
assert.ok(localInstall > override);
assert.ok(machineInstall > localInstall);
assert.match(content, /SubMiner app not found/);
});
test('configured app path is an escaped fallback after the environment override', () => {
const configured = 'D:\\Apps & Tools\\SubMiner 100% !\\SubMiner.exe';
const content = windowsLauncherBootstrapContent(configured);
assert.ok(
content.indexOf('if defined SUBMINER_BINARY_PATH goto subminer_app_found') <
content.indexOf('if not exist "D:\\Apps & Tools\\SubMiner 100%% !\\SubMiner.exe"'),
);
assert.match(
content,
/set "SUBMINER_BINARY_PATH=D:\\Apps & Tools\\SubMiner 100%% !\\SubMiner\.exe"/,
);
assert.throws(
() => windowsLauncherBootstrapContent('C:\\Bad "Install"\\SubMiner.exe'),
/quotes or newlines/,
);
});
test('cached runtime is the fast path and launcher arguments remain opaque to the batch file', () => {
const content = windowsLauncherBootstrapContent();
const cacheCheck = content.indexOf('if exist "%SUBMINER_BUN_PATH%" goto subminer_run');
const electronPrepare = content.indexOf('set "ELECTRON_RUN_AS_NODE=1"');
const run = content.indexOf(
'"%SUBMINER_BUN_PATH%" "%SUBMINER_RESOURCES_PATH%\\launcher\\subminer.js" %*',
);
assert.match(content, /^@echo off\r\nrem SubMiner managed launcher \(bundled runtime\)/);
assert.match(content, /setlocal DisableDelayedExpansion/);
assert.ok(cacheCheck >= 0);
assert.ok(electronPrepare > cacheCheck);
assert.ok(run > electronPrepare);
assert.doesNotMatch(content, /powershell/i);
assert.doesNotMatch(content, /^\s*(?:call\s+)?bun(?:\.exe)?(?:\s|$)/im);
});
test('preparation failures keep their exit code and never continue to the launcher', () => {
const content = windowsLauncherBootstrapContent();
assert.match(content, /set "SUBMINER_PREPARE_EXIT=%errorlevel%"/);
assert.match(content, /if not "%SUBMINER_PREPARE_EXIT%"=="0" exit \/b %SUBMINER_PREPARE_EXIT%/);
assert.match(content, /if not exist "%SUBMINER_BUN_PATH%" goto subminer_prepare_missing/);
assert.match(content, /set "ELECTRON_RUN_AS_NODE="/);
});
test('Windows bootstrap prepares once and forwards metacharacter arguments', async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const appDirectory = path.join(root, 'Installed & App 100% !');
const appPath = path.join(appDirectory, 'SubMiner.exe');
const resourcesPath = path.join(appDirectory, 'resources');
const launcherDirectory = path.join(resourcesPath, 'launcher');
const localAppData = path.join(root, 'Local App Data');
const bootstrapPath = path.join(root, 'subminer.cmd');
const version = '1.2.3-test';
const cachedBunPath = path.join(localAppData, 'SubMiner', 'launcher-runtime', version, 'bun.exe');
fs.mkdirSync(launcherDirectory, { recursive: true });
fs.copyFileSync(process.execPath, appPath);
fs.writeFileSync(path.join(launcherDirectory, 'version'), version);
fs.writeFileSync(
path.join(launcherDirectory, 'subminer.js'),
'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,resources:process.env.SUBMINER_RESOURCES_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));',
);
fs.writeFileSync(
path.join(launcherDirectory, 'prepare.cjs'),
`const fs=require('node:fs');const path=require('node:path');exports.prepareLauncherRuntime=()=>{const target=${JSON.stringify(cachedBunPath)};fs.mkdirSync(path.dirname(target),{recursive:true});fs.copyFileSync(process.execPath,target);};`,
);
fs.writeFileSync(bootstrapPath, windowsLauncherBootstrapContent(appPath));
const args = [
'spaces here',
'100%',
'p%TEMP%q',
'bang!',
'a&b',
'x|y',
'<left>',
'caret^',
'say "hi"',
'日本語',
];
const env = { ...process.env, PATH: '', Path: '', LOCALAPPDATA: localAppData };
const first = await getRunCommand({})(bootstrapPath, args, { env });
assert.equal(first.exitCode, 0, first.stderr);
assert.ok(fs.existsSync(cachedBunPath));
const firstPayload: unknown = JSON.parse(first.stdout);
assert.deepEqual(firstPayload, {
args,
app: appPath,
resources: resourcesPath,
managed: '1',
});
fs.writeFileSync(
path.join(launcherDirectory, 'prepare.cjs'),
"throw new Error('cached launch should not prepare');",
);
const second = await getRunCommand({})(bootstrapPath, args, { env });
assert.equal(second.exitCode, 0, second.stderr);
const secondPayload: unknown = JSON.parse(second.stdout);
assert.deepEqual(secondPayload, firstPayload);
});
@@ -0,0 +1,75 @@
const MANAGED_LAUNCHER_MARKER = 'SubMiner managed launcher (bundled runtime)';
function windowsBatchLiteral(value: string): string {
if (/["\r\n]/.test(value)) {
throw new Error('Launcher paths cannot contain quotes or newlines.');
}
return value.replaceAll('%', '%%');
}
function configuredAppCandidate(appPath: string | undefined): string[] {
if (!appPath) return [];
const literal = windowsBatchLiteral(appPath);
return [
`if not exist "${literal}" goto subminer_check_local_app`,
`set "SUBMINER_BINARY_PATH=${literal}"`,
'goto subminer_app_found',
];
}
// This command file stays valid across app updates. It locates the current app
// and only starts Electron in Node mode when that version's private Bun is absent.
export function windowsLauncherBootstrapContent(appPath?: string): string {
return [
'@echo off',
`rem ${MANAGED_LAUNCHER_MARKER}`,
'setlocal DisableDelayedExpansion',
'set "SUBMINER_MANAGED_LAUNCHER=1"',
'set "SUBMINER_LAUNCHER_PATH=%~f0"',
'if defined SUBMINER_BINARY_PATH goto subminer_app_found',
...configuredAppCandidate(appPath),
':subminer_check_local_app',
'if not defined LOCALAPPDATA goto subminer_check_program_files',
'if not exist "%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" goto subminer_check_program_files',
'set "SUBMINER_BINARY_PATH=%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe"',
'goto subminer_app_found',
':subminer_check_program_files',
'if not defined ProgramFiles goto subminer_app_missing',
'if not exist "%ProgramFiles%\\SubMiner\\SubMiner.exe" goto subminer_app_missing',
'set "SUBMINER_BINARY_PATH=%ProgramFiles%\\SubMiner\\SubMiner.exe"',
':subminer_app_found',
'if not exist "%SUBMINER_BINARY_PATH%" goto subminer_app_missing',
'if not defined LOCALAPPDATA goto subminer_local_app_data_missing',
'for %%I in ("%SUBMINER_BINARY_PATH%") do set "SUBMINER_RESOURCES_PATH=%%~dpIresources"',
'if not exist "%SUBMINER_RESOURCES_PATH%\\launcher\\subminer.js" goto subminer_resources_missing',
'set "SUBMINER_APP_VERSION="',
'if not exist "%SUBMINER_RESOURCES_PATH%\\launcher\\version" goto subminer_resources_missing',
'set /p "SUBMINER_APP_VERSION="<"%SUBMINER_RESOURCES_PATH%\\launcher\\version"',
'if not defined SUBMINER_APP_VERSION goto subminer_resources_missing',
'set "SUBMINER_BUN_PATH=%LOCALAPPDATA%\\SubMiner\\launcher-runtime\\%SUBMINER_APP_VERSION%\\bun.exe"',
'if exist "%SUBMINER_BUN_PATH%" goto subminer_run',
'if not exist "%SUBMINER_RESOURCES_PATH%\\launcher\\prepare.cjs" goto subminer_resources_missing',
'set "ELECTRON_RUN_AS_NODE=1"',
"\"%SUBMINER_BINARY_PATH%\" -e \"const p=require('node:path');try{require(p.join(process.env.SUBMINER_RESOURCES_PATH,'launcher','prepare.cjs')).prepareLauncherRuntime({appPath:process.env.SUBMINER_BINARY_PATH,resourcesPath:process.env.SUBMINER_RESOURCES_PATH});}catch(error){console.error('Cannot prepare SubMiner launcher. Update or reinstall the SubMiner app.',error instanceof Error?error.message:String(error));process.exit(1);}\"",
'set "SUBMINER_PREPARE_EXIT=%errorlevel%"',
'set "ELECTRON_RUN_AS_NODE="',
'if not "%SUBMINER_PREPARE_EXIT%"=="0" exit /b %SUBMINER_PREPARE_EXIT%',
'if not exist "%SUBMINER_BUN_PATH%" goto subminer_prepare_missing',
':subminer_run',
'"%SUBMINER_BUN_PATH%" "%SUBMINER_RESOURCES_PATH%\\launcher\\subminer.js" %*',
'exit /b %errorlevel%',
':subminer_app_missing',
'>&2 echo SubMiner app not found. Install the app or set SUBMINER_BINARY_PATH to its executable.',
'exit /b 1',
':subminer_local_app_data_missing',
'>&2 echo LOCALAPPDATA is unavailable. SubMiner cannot locate its private launcher runtime.',
'exit /b 1',
':subminer_resources_missing',
'>&2 echo This launcher requires a SubMiner app with the included Bun runtime. Update SubMiner.',
'exit /b 1',
':subminer_prepare_missing',
'>&2 echo SubMiner did not create its private launcher runtime. Update or reinstall SubMiner.',
'exit /b 1',
'',
].join('\r\n');
}
+4 -2
View File
@@ -14,9 +14,10 @@ import {
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
runSsh,
} from '../core/services/stats-sync/ssh';
import { createSnapshotTransfer } from '../core/services/stats-sync/snapshot-transfer';
import { createTransferCache } from '../core/services/stats-sync/transfer-cache';
import {
ensureTrackerQuiescentFlow,
runSyncFlow,
@@ -63,7 +64,8 @@ function buildSyncCliDeps(): SyncFlowDeps {
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
createSnapshotTransfer,
transferCache: createTransferCache(),
runSsh,
canConnectUnixSocket: canConnectSocket,
realpathSync: (candidate) => fs.realpathSync(candidate),