From 57a7e5247dd4f48b1f7844e5934888e4d79067cf Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 3 Apr 2026 17:31:42 -0700 Subject: [PATCH] fix: unblock playback when mpv plugin is already installed --- ...nonical-mpv-plugin-is-already-installed.md | 53 +++++++++++ .../fix-first-run-setup-plugin-detection.md | 5 ++ launcher/commands/playback-command.ts | 10 +++ launcher/setup-gate.test.ts | 90 +++++++++++++++++++ launcher/setup-gate.ts | 21 ++++- .../runtime/first-run-setup-plugin.test.ts | 69 ++++++++++++++ src/main/runtime/first-run-setup-plugin.ts | 23 ++--- src/shared/setup-state.test.ts | 27 ++---- src/shared/setup-state.ts | 4 +- 9 files changed, 260 insertions(+), 42 deletions(-) create mode 100644 backlog/tasks/task-273 - Fix-first-run-setup-false-positive-when-canonical-mpv-plugin-is-already-installed.md create mode 100644 changes/fix-first-run-setup-plugin-detection.md diff --git a/backlog/tasks/task-273 - Fix-first-run-setup-false-positive-when-canonical-mpv-plugin-is-already-installed.md b/backlog/tasks/task-273 - Fix-first-run-setup-false-positive-when-canonical-mpv-plugin-is-already-installed.md new file mode 100644 index 00000000..28b93a9b --- /dev/null +++ b/backlog/tasks/task-273 - Fix-first-run-setup-false-positive-when-canonical-mpv-plugin-is-already-installed.md @@ -0,0 +1,53 @@ +--- +id: TASK-273 +title: >- + Fix first-run setup false positive when canonical mpv plugin is already + installed +status: Done +assignee: + - Kyle Yasuda +created_date: '2026-04-03 23:26' +updated_date: '2026-04-04 00:31' +labels: + - bug + - macos + - first-run-setup +dependencies: [] +priority: high +--- + +## Description + + +Investigate and fix launcher/app first-run setup gating so playback does not block when the SubMiner mpv plugin is already installed at the canonical mpv config path on macOS. Align mpv path resolution with the actual install location, keep plugin detection scoped to the canonical plugin entrypoint, and make launcher setup gating resilient to stale cancelled setup state. + + +## Acceptance Criteria + +- [ ] #1 `resolveDefaultMpvInstallPaths` resolves the canonical macOS mpv config path used by existing installs. +- [ ] #2 Playback launcher bypasses first-run setup when the canonical `scripts/subminer/main.lua` plugin entrypoint already exists, even if `setup-state.json` is stale. +- [ ] #3 Regression tests cover canonical plugin detection and launcher handling of stale cancelled setup state. + + +## Implementation Notes + + +Root cause ended up split across path resolution and launcher gating. No automated test command was executed in this pass by request. + + +## Final Summary + + +Updated macOS mpv install path resolution to use the canonical `~/.config/mpv` location so first-run plugin detection matches the actual installed plugin path. + +Restricted plugin detection to the canonical `scripts/subminer/main.lua` entrypoint instead of config presence or legacy loader files. + +Updated the launcher setup gate to bypass stale `setup-state.json` when the mpv plugin is already installed, and to ignore an initially stale `cancelled` state after spawning setup. + +Added regression coverage for canonical macOS detection and launcher setup-gate bypass behavior. No automated test command was executed in this pass by request. + + +## Definition of Done + +- [ ] #1 Manual verification with scenario: existing plugin installed in custom mpv config path does not open first-run setup. + diff --git a/changes/fix-first-run-setup-plugin-detection.md b/changes/fix-first-run-setup-plugin-detection.md new file mode 100644 index 00000000..972e0a64 --- /dev/null +++ b/changes/fix-first-run-setup-plugin-detection.md @@ -0,0 +1,5 @@ +type: fixed +area: launcher + +Fixed first-run setup blocking playback on macOS when the SubMiner mpv plugin was already installed at the canonical `~/.config/mpv` path. +Fixed launcher setup gating so stale cancelled setup state no longer prevents playback when the canonical mpv plugin entrypoint already exists. diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index f0c8da96..c5ffad66 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -21,7 +21,9 @@ import { getDefaultConfigDir, getSetupStatePath, readSetupState, + resolveDefaultMpvInstallPaths, } from '../../src/shared/setup-state.js'; +import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js'; import { hasLauncherExternalYomitanProfileConfig } from '../config.js'; const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000; @@ -105,6 +107,14 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis const ready = await ensureLauncherSetupReady({ readSetupState: () => readSetupState(statePath), isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(), + isPluginInstalled: () => { + const installPaths = resolveDefaultMpvInstallPaths( + process.platform, + os.homedir(), + process.env.XDG_CONFIG_HOME, + ); + return detectInstalledFirstRunPlugin(installPaths); + }, launchSetupApp: () => { const setupArgs = ['--background', '--setup']; if (args.logLevel) { diff --git a/launcher/setup-gate.test.ts b/launcher/setup-gate.test.ts index 2e6c1b8b..85ed3101 100644 --- a/launcher/setup-gate.test.ts +++ b/launcher/setup-gate.test.ts @@ -116,6 +116,36 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf assert.deepEqual(calls, []); }); +test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => { + const calls: string[] = []; + + const ready = await ensureLauncherSetupReady({ + readSetupState: () => ({ + version: 3, + status: 'cancelled', + completedAt: null, + completionSource: null, + yomitanSetupMode: null, + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, + windowsMpvShortcutLastStatus: 'unknown', + }), + isPluginInstalled: () => true, + launchSetupApp: () => { + calls.push('launch'); + }, + sleep: async () => undefined, + now: () => 0, + timeoutMs: 5_000, + pollIntervalMs: 100, + }); + + assert.equal(ready, true); + assert.deepEqual(calls, []); +}); + test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => { const result = await ensureLauncherSetupReady({ readSetupState: () => ({ @@ -139,3 +169,63 @@ test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => { assert.equal(result, false); }); + +test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => { + let reads = 0; + + const result = await ensureLauncherSetupReady({ + readSetupState: () => { + reads += 1; + if (reads <= 2) { + return { + version: 3, + status: 'cancelled', + completedAt: null, + completionSource: null, + yomitanSetupMode: null, + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, + windowsMpvShortcutLastStatus: 'unknown', + }; + } + if (reads === 3) { + return { + version: 3, + status: 'in_progress', + completedAt: null, + completionSource: null, + yomitanSetupMode: null, + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, + windowsMpvShortcutLastStatus: 'unknown', + }; + } + return { + version: 3, + status: 'completed', + completedAt: '2026-03-07T00:00:00.000Z', + completionSource: 'legacy_auto_detected', + yomitanSetupMode: 'internal', + lastSeenYomitanDictionaryCount: 1, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: '/tmp/mpv', + windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, + windowsMpvShortcutLastStatus: 'unknown', + }; + }, + launchSetupApp: () => undefined, + sleep: async () => undefined, + now: (() => { + let value = 0; + return () => (value += 100); + })(), + timeoutMs: 5_000, + pollIntervalMs: 100, + }); + + assert.equal(result, true); +}); diff --git a/launcher/setup-gate.ts b/launcher/setup-gate.ts index ebd9b36c..2026018e 100644 --- a/launcher/setup-gate.ts +++ b/launcher/setup-gate.ts @@ -6,15 +6,24 @@ export async function waitForSetupCompletion(deps: { now: () => number; timeoutMs: number; pollIntervalMs: number; + ignoreInitialCancelledState?: boolean; }): Promise<'completed' | 'cancelled' | 'timeout'> { const deadline = deps.now() + deps.timeoutMs; + let ignoringCancelled = deps.ignoreInitialCancelledState === true; while (deps.now() <= deadline) { const state = deps.readSetupState(); if (isSetupCompleted(state)) { return 'completed'; } + if (ignoringCancelled && state?.status !== 'cancelled') { + ignoringCancelled = false; + } if (state?.status === 'cancelled') { + if (ignoringCancelled) { + await deps.sleep(deps.pollIntervalMs); + continue; + } return 'cancelled'; } await deps.sleep(deps.pollIntervalMs); @@ -26,6 +35,7 @@ export async function waitForSetupCompletion(deps: { export async function ensureLauncherSetupReady(deps: { readSetupState: () => SetupState | null; isExternalYomitanConfigured?: () => boolean; + isPluginInstalled?: () => boolean; launchSetupApp: () => void; sleep: (ms: number) => Promise; now: () => number; @@ -35,11 +45,18 @@ export async function ensureLauncherSetupReady(deps: { if (deps.isExternalYomitanConfigured?.()) { return true; } - if (isSetupCompleted(deps.readSetupState())) { + if (deps.isPluginInstalled?.()) { + return true; + } + const initialState = deps.readSetupState(); + if (isSetupCompleted(initialState)) { return true; } deps.launchSetupApp(); - const result = await waitForSetupCompletion(deps); + const result = await waitForSetupCompletion({ + ...deps, + ignoreInitialCancelledState: initialState?.status === 'cancelled', + }); return result === 'completed'; } diff --git a/src/main/runtime/first-run-setup-plugin.test.ts b/src/main/runtime/first-run-setup-plugin.test.ts index e43c13b0..0e7789de 100644 --- a/src/main/runtime/first-run-setup-plugin.test.ts +++ b/src/main/runtime/first-run-setup-plugin.test.ts @@ -225,3 +225,72 @@ test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overr ); }); }); + +test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv config location on macOS', () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir); + const pluginDir = path.join(homeDir, '.config', 'mpv', 'scripts', 'subminer'); + const pluginEntrypointPath = path.join(pluginDir, 'main.lua'); + + fs.mkdirSync(pluginDir, { recursive: true }); + fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true }); + fs.writeFileSync(pluginEntrypointPath, '-- plugin'); + + assert.equal( + detectInstalledFirstRunPlugin(installPaths), + true, + ); + }); +}); + +test('detectInstalledFirstRunPlugin ignores scoped plugin layout path', () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir, xdgConfigHome); + const pluginDir = path.join(xdgConfigHome, 'mpv', 'scripts', '@plugin', 'subminer'); + const pluginEntrypointPath = path.join(pluginDir, 'main.lua'); + + fs.mkdirSync(pluginDir, { recursive: true }); + fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true }); + fs.writeFileSync(pluginEntrypointPath, '-- plugin'); + + assert.equal( + detectInstalledFirstRunPlugin(installPaths), + false, + ); + }); +}); + +test('detectInstalledFirstRunPlugin ignores legacy loader file', () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir); + const legacyLoaderPath = path.join(installPaths.scriptsDir, 'subminer.lua'); + + fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true }); + fs.writeFileSync(legacyLoaderPath, '-- plugin'); + + assert.equal( + detectInstalledFirstRunPlugin(installPaths), + false, + ); + }); +}); + +test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir); + const pluginDir = path.join(installPaths.scriptsDir, 'subminer'); + + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin'); + + assert.equal( + detectInstalledFirstRunPlugin(installPaths), + false, + ); + }); +}); diff --git a/src/main/runtime/first-run-setup-plugin.ts b/src/main/runtime/first-run-setup-plugin.ts index 0cda63a1..e1a2fb9d 100644 --- a/src/main/runtime/first-run-setup-plugin.ts +++ b/src/main/runtime/first-run-setup-plugin.ts @@ -12,14 +12,6 @@ function backupExistingPath(targetPath: string): void { fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`); } -function resolveLegacyPluginLoaderPath(installPaths: MpvInstallPaths): string { - return path.join(installPaths.scriptsDir, 'subminer.lua'); -} - -function resolveLegacyPluginDebugLoaderPath(installPaths: MpvInstallPaths): string { - return path.join(installPaths.scriptsDir, 'subminer-loader.lua'); -} - function rewriteInstalledWindowsPluginConfig(configPath: string): void { const content = fs.readFileSync(configPath, 'utf8'); const updated = content.replace(/^socket_path=.*$/m, 'socket_path=\\\\.\\pipe\\subminer-socket'); @@ -99,14 +91,13 @@ export function resolvePackagedFirstRunPluginAssets(deps: { export function detectInstalledFirstRunPlugin( installPaths: MpvInstallPaths, - deps?: { existsSync?: (candidate: string) => boolean }, + deps?: { + existsSync?: (candidate: string) => boolean; + }, ): boolean { const existsSync = deps?.existsSync ?? fs.existsSync; - return ( - existsSync(installPaths.pluginEntrypointPath) && - existsSync(installPaths.pluginDir) && - existsSync(installPaths.pluginConfigPath) - ); + const pluginEntrypointPath = path.join(installPaths.scriptsDir, 'subminer', 'main.lua'); + return existsSync(pluginEntrypointPath); } export function installFirstRunPluginToDefaultLocation(options: { @@ -148,8 +139,8 @@ export function installFirstRunPluginToDefaultLocation(options: { fs.mkdirSync(installPaths.scriptsDir, { recursive: true }); fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true }); - backupExistingPath(resolveLegacyPluginLoaderPath(installPaths)); - backupExistingPath(resolveLegacyPluginDebugLoaderPath(installPaths)); + backupExistingPath(path.join(installPaths.scriptsDir, 'subminer.lua')); + backupExistingPath(path.join(installPaths.scriptsDir, 'subminer-loader.lua')); backupExistingPath(installPaths.pluginDir); backupExistingPath(installPaths.pluginConfigPath); fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true }); diff --git a/src/shared/setup-state.test.ts b/src/shared/setup-state.test.ts index 80d57258..d52a24b4 100644 --- a/src/shared/setup-state.test.ts +++ b/src/shared/setup-state.test.ts @@ -191,36 +191,21 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults' const macHomeDir = path.join(path.sep, 'Users', 'tester'); assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', macHomeDir, undefined), { supported: true, - mpvConfigDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv'), - scriptsDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv', 'scripts'), - scriptOptsDir: path.posix.join( - macHomeDir, - 'Library', - 'Application Support', - 'mpv', - 'script-opts', - ), + mpvConfigDir: path.posix.join(macHomeDir, '.config', 'mpv'), + scriptsDir: path.posix.join(macHomeDir, '.config', 'mpv', 'scripts'), + scriptOptsDir: path.posix.join(macHomeDir, '.config', 'mpv', 'script-opts'), pluginEntrypointPath: path.posix.join( macHomeDir, - 'Library', - 'Application Support', + '.config', 'mpv', 'scripts', 'subminer', 'main.lua', ), - pluginDir: path.posix.join( - macHomeDir, - 'Library', - 'Application Support', - 'mpv', - 'scripts', - 'subminer', - ), + pluginDir: path.posix.join(macHomeDir, '.config', 'mpv', 'scripts', 'subminer'), pluginConfigPath: path.posix.join( macHomeDir, - 'Library', - 'Application Support', + '.config', 'mpv', 'script-opts', 'subminer.conf', diff --git a/src/shared/setup-state.ts b/src/shared/setup-state.ts index 82bf86a6..dd5f6828 100644 --- a/src/shared/setup-state.ts +++ b/src/shared/setup-state.ts @@ -241,9 +241,7 @@ export function resolveDefaultMpvInstallPaths( ): MpvInstallPaths { const platformPath = getPlatformPath(platform); const mpvConfigDir = - platform === 'darwin' - ? platformPath.join(homeDir, 'Library', 'Application Support', 'mpv') - : platform === 'linux' + platform === 'linux' || platform === 'darwin' ? platformPath.join(xdgConfigHome?.trim() || platformPath.join(homeDir, '.config'), 'mpv') : platformPath.join(homeDir, 'AppData', 'Roaming', 'mpv');