mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-15 08:12:53 -07:00
feat: add auto update support
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface Args {
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
update?: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
|
||||
Reference in New Issue
Block a user