feat: add first-run setup flow

This commit is contained in:
2026-03-07 00:57:09 -08:00
parent 755c1175b0
commit 3dff6c2515
46 changed files with 2043 additions and 25 deletions

View File

@@ -1,4 +1,6 @@
import fs from 'node:fs';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { fail, log } from '../log.js';
import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from '../util.js';
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
@@ -13,6 +15,11 @@ import {
import { generateYoutubeSubtitles } from '../youtube.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
import { ensureLauncherSetupReady } from '../setup-gate.js';
import { getDefaultConfigDir, getSetupStatePath, readSetupState } from '../../src/shared/setup-state.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500;
function checkDependencies(args: Args): void {
const missing: string[] = [];
@@ -84,12 +91,47 @@ function registerCleanup(context: LauncherCommandContext): void {
});
}
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
const { args, appPath } = context;
if (!appPath) return;
const configDir = getDefaultConfigDir({
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
});
const statePath = getSetupStatePath(configDir);
const ready = await ensureLauncherSetupReady({
readSetupState: () => readSetupState(statePath),
launchSetupApp: () => {
const setupArgs = ['--background', '--setup'];
if (args.logLevel) {
setupArgs.push('--log-level', args.logLevel);
}
const child = spawn(appPath, setupArgs, {
detached: true,
stdio: 'ignore',
});
child.unref();
},
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
now: () => Date.now(),
timeoutMs: SETUP_WAIT_TIMEOUT_MS,
pollIntervalMs: SETUP_POLL_INTERVAL_MS,
});
if (!ready) {
fail('SubMiner setup is incomplete. Complete setup in the app, then retry playback.');
}
}
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
if (!appPath) {
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
await ensurePlaybackSetupReady(context);
if (!args.target) {
checkPickerDependencies(args);
}

107
launcher/setup-gate.test.ts Normal file
View File

@@ -0,0 +1,107 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
import type { SetupState } from '../src/shared/setup-state';
test('waitForSetupCompletion resolves completed and cancelled states', async () => {
const sequence: Array<SetupState | null> = [
null,
{
version: 1,
status: 'in_progress',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
},
{
version: 1,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'skipped',
pluginInstallPathSummary: null,
},
];
const result = await waitForSetupCompletion({
readSetupState: () => sequence.shift() ?? null,
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(result, 'completed');
});
test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => {
const calls: string[] = [];
let reads = 0;
const ready = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
if (reads === 1) return null;
if (reads === 2) {
return {
version: 1,
status: 'in_progress',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
};
}
return {
version: 1,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
};
},
launchSetupApp: () => {
calls.push('launch');
},
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(ready, true);
assert.deepEqual(calls, ['launch']);
});
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({
readSetupState: () => ({
version: 1,
status: 'cancelled',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
}),
launchSetupApp: () => undefined,
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(result, false);
});

41
launcher/setup-gate.ts Normal file
View File

@@ -0,0 +1,41 @@
import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js';
export async function waitForSetupCompletion(deps: {
readSetupState: () => SetupState | null;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<'completed' | 'cancelled' | 'timeout'> {
const deadline = deps.now() + deps.timeoutMs;
while (deps.now() <= deadline) {
const state = deps.readSetupState();
if (isSetupCompleted(state)) {
return 'completed';
}
if (state?.status === 'cancelled') {
return 'cancelled';
}
await deps.sleep(deps.pollIntervalMs);
}
return 'timeout';
}
export async function ensureLauncherSetupReady(deps: {
readSetupState: () => SetupState | null;
launchSetupApp: () => void;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<boolean> {
if (isSetupCompleted(deps.readSetupState())) {
return true;
}
deps.launchSetupApp();
const result = await waitForSetupCompletion(deps);
return result === 'completed';
}