feat: add auto update support

This commit is contained in:
2026-05-15 01:47:56 -07:00
parent d1ec678d7a
commit 094bcce0dc
101 changed files with 4978 additions and 163 deletions
+33
View File
@@ -8,6 +8,7 @@ import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js';
import { runMpvPreAppCommand } from './mpv-command.js';
import { runStatsCommand } from './stats-command.js';
import { runUpdateCommand } from './update-command.js';
class ExitSignal extends Error {
code: number;
@@ -240,6 +241,38 @@ test('dictionary command returns after app handoff starts', () => {
assert.equal(handled, true);
});
test('update command forwards launcher path and waits for response', async () => {
const context = createContext();
context.args.update = true;
const forwarded: string[][] = [];
const responses: string[] = [];
const handled = await runUpdateCommand(context, {
createTempDir: () => '/tmp/subminer-update-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (_appPath, appArgs) => {
forwarded.push(appArgs);
return { status: 0, stdout: '', stderr: '' };
},
waitForUpdateResponse: async (responsePath) => {
responses.push(responsePath);
return { ok: true, status: 'up-to-date', version: '0.15.0' };
},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [
[
'--update',
'--update-launcher-path',
'/tmp/subminer',
'--update-response-path',
'/tmp/subminer-update-test/response.json',
],
]);
assert.deepEqual(responses, ['/tmp/subminer-update-test/response.json']);
});
test('stats command launches attached app command with response path', async () => {
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
const handled = await runStatsCommand(harness.context, harness.commandDeps);
+87
View File
@@ -0,0 +1,87 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runAppCommandCaptureOutput } from '../mpv.js';
import { nowMs } from '../time.js';
import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.js';
type UpdateCommandResponse = {
ok: boolean;
status?: string;
version?: string;
error?: string;
};
type UpdateCommandDeps = {
createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string;
runAppCommandCaptureOutput: (
appPath: string,
appArgs: string[],
) => { status: number; stdout: string; stderr: string; error?: Error };
waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>;
removeDir: (targetPath: string) => void;
};
const UPDATE_RESPONSE_TIMEOUT_MS = 10 * 60 * 1000;
const defaultDeps: UpdateCommandDeps = {
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
joinPath: (...parts) => path.join(...parts),
runAppCommandCaptureOutput: (appPath, appArgs) => runAppCommandCaptureOutput(appPath, appArgs),
waitForUpdateResponse: async (responsePath) => {
const deadline = nowMs() + UPDATE_RESPONSE_TIMEOUT_MS;
while (nowMs() < deadline) {
try {
if (fs.existsSync(responsePath)) {
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as UpdateCommandResponse;
}
} catch {
// retry until timeout
}
await sleep(100);
}
return { ok: false, error: 'Timed out waiting for SubMiner update response.' };
},
removeDir: (targetPath) => {
fs.rmSync(targetPath, { recursive: true, force: true });
},
};
export async function runUpdateCommand(
context: LauncherCommandContext,
deps: Partial<UpdateCommandDeps> = {},
): Promise<boolean> {
const resolvedDeps: UpdateCommandDeps = { ...defaultDeps, ...deps };
const { args, appPath, scriptPath } = context;
if (!args.update || !appPath) {
return false;
}
const tempDir = resolvedDeps.createTempDir('subminer-update-');
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
try {
const result = resolvedDeps.runAppCommandCaptureOutput(appPath, [
'--update',
'--update-launcher-path',
scriptPath,
'--update-response-path',
responsePath,
]);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`SubMiner update command exited with status ${result.status}.`);
}
const response = await resolvedDeps.waitForUpdateResponse(responsePath);
if (!response.ok) {
throw new Error(response.error || 'SubMiner update check failed.');
}
return true;
} finally {
resolvedDeps.removeDir(tempDir);
}
}
+2
View File
@@ -156,6 +156,7 @@ export function createDefaultArgs(
statsCleanupLifetime: false,
doctor: false,
doctorRefreshKnownWords: false,
update: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -217,6 +218,7 @@ export function applyRootOptionsToArgs(
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
if (options.rofi === true) parsed.useRofi = true;
if (options.update === true) parsed.update = true;
if (options.startOverlay === true) parsed.autoStartOverlay = true;
if (options.texthooker === false) parsed.useTexthooker = false;
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
+1
View File
@@ -57,6 +57,7 @@ function applyRootOptions(program: Command): void {
.option('-p, --profile <profile>', 'MPV profile')
.option('--start', 'Explicitly start overlay')
.option('--log-level <level>', 'Log level')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-S, --start-overlay', 'Auto-start overlay')
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
+5
View File
@@ -18,6 +18,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js';
function createCommandContext(
args: ReturnType<typeof parseArgs>,
@@ -86,6 +87,10 @@ async function main(): Promise<void> {
return;
}
if (await runUpdateCommand(appContext)) {
return;
}
if (await runMpvPostAppCommand(appContext)) {
return;
}
+19
View File
@@ -1315,6 +1315,25 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
});
}
export function runAppCommandSilently(appPath: string, appArgs: string[]): void {
if (maybeCaptureAppArgs(appArgs)) {
process.exit(0);
}
const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
});
attachAppProcessLogging(proc);
proc.once('error', (error) => {
fail(`Failed to run app command: ${error.message}`);
});
proc.once('close', (code) => {
process.exit(code ?? 0);
});
}
export function runAppCommandCaptureOutput(
appPath: string,
appArgs: string[],
+12
View File
@@ -57,6 +57,18 @@ test('parseArgs captures mpv args string', () => {
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
});
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
const shortParsed = parseArgs(['-u'], 'subminer', {});
const longParsed = parseArgs(['--update'], 'subminer', {});
const jellyfinParsed = parseArgs(['jellyfin', 'setup', '-u', 'kyle'], 'subminer', {});
assert.equal(shortParsed.update, true);
assert.equal(longParsed.update, true);
assert.equal(jellyfinParsed.update, false);
assert.equal(jellyfinParsed.jellyfin, true);
assert.equal(jellyfinParsed.jellyfinUsername, 'kyle');
});
test('parseArgs maps jellyfin play action and log-level override', () => {
const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {});
+26 -10
View File
@@ -3,11 +3,17 @@ import assert from 'node:assert/strict';
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
import type { SetupState } from '../src/shared/setup-state';
const commandLineSetupDefaults = {
bunInstallStatus: 'unknown',
launcherInstallStatus: 'unknown',
launcherInstallPath: null,
} satisfies Pick<SetupState, 'bunInstallStatus' | 'launcherInstallStatus' | 'launcherInstallPath'>;
test('waitForSetupCompletion resolves completed and cancelled states', async () => {
const sequence: Array<SetupState | null> = [
null,
{
version: 3,
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
@@ -17,9 +23,10 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
},
{
version: 3,
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
@@ -29,6 +36,7 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'skipped',
...commandLineSetupDefaults,
},
];
@@ -56,7 +64,7 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
if (reads === 1) return null;
if (reads === 2) {
return {
version: 3,
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
@@ -66,10 +74,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
return {
version: 3,
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
@@ -79,6 +88,7 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'installed',
...commandLineSetupDefaults,
};
},
launchSetupApp: () => {
@@ -125,7 +135,7 @@ test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal'
readSetupState: () => {
reads += 1;
return {
version: 3,
version: 4,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
completionSource: 'user',
@@ -135,6 +145,7 @@ test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal'
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
hasLegacyMpvPlugin: () => legacyPluginInstalled,
@@ -164,7 +175,7 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
readSetupState: () => {
reads += 1;
return {
version: 3,
version: 4,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
completionSource: 'user',
@@ -174,6 +185,7 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
hasLegacyMpvPlugin: () => true,
@@ -196,7 +208,7 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({
readSetupState: () => ({
version: 3,
version: 4,
status: 'cancelled',
completedAt: null,
completionSource: null,
@@ -206,6 +218,7 @@ test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
}),
launchSetupApp: () => undefined,
sleep: async () => undefined,
@@ -228,7 +241,7 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
reads += 1;
if (reads <= 2) {
return {
version: 3,
version: 4,
status: 'cancelled',
completedAt: null,
completionSource: null,
@@ -238,11 +251,12 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
if (reads === 3) {
return {
version: 3,
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
@@ -252,10 +266,11 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
return {
version: 3,
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'legacy_auto_detected',
@@ -265,6 +280,7 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
launchSetupApp: () => undefined,
+1
View File
@@ -134,6 +134,7 @@ export interface Args {
dictionaryTarget?: string;
doctor: boolean;
doctorRefreshKnownWords: boolean;
update?: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;