fix: unblock playback when mpv plugin is already installed

This commit is contained in:
2026-04-03 17:31:42 -07:00
parent e7734b76a7
commit 57a7e5247d
9 changed files with 260 additions and 42 deletions

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause ended up split across path resolution and launcher gating. No automated test command was executed in this pass by request.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->
## Definition of Done
<!-- DOD:BEGIN -->
- [ ] #1 Manual verification with scenario: existing plugin installed in custom mpv config path does not open first-run setup.
<!-- DOD:END -->

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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);
});

View File

@@ -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<void>;
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';
}

View File

@@ -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,
);
});
});

View File

@@ -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 });

View File

@@ -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',

View File

@@ -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');