feat: inject bundled mpv plugin for managed launches, remove legacy glob (#62)

* feat: inject bundled mpv plugin for managed launches, remove legacy glob

- SubMiner-managed launcher and Windows shortcut launches inject the bundled plugin when no global plugin is detected
- First-run setup detects and removes legacy global plugin files via OS trash before managed playback starts
- Makefile `install-plugin` target and Windows config-rewrite script removed; Linux/macOS install now copies plugin to app data dir
- AniList stats search and post-watch tracking now go through the shared rate limiter
- Stats cover-art lookup reuses cached AniList data before issuing a new request
- Closing mpv in a launcher-managed session now terminates the background Electron app

* harden bootstrap version load and clean plugin on uninstall

- Use pcall for version.lua in bootstrap.lua so missing version module does not crash plugin startup
- Remove plugin/subminer from app-data dirs in uninstall-linux and uninstall-macos targets
- Add Lua compat test asserting bootstrap uses defensive pcall for version load
- Add release-workflow test asserting uninstall targets clean bundled plugin dirs
- Delete completed planning document
This commit is contained in:
2026-05-12 23:11:19 -07:00
committed by GitHub
parent e5c1135501
commit 7c9b65db8b
43 changed files with 2116 additions and 481 deletions
+13 -3
View File
@@ -1,5 +1,9 @@
import { fail, log } from '../log.js';
import { waitForUnixSocketReady, launchMpvIdleDetached } from '../mpv.js';
import {
waitForUnixSocketReady,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
} from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
interface MpvCommandDeps {
@@ -8,6 +12,7 @@ interface MpvCommandDeps {
socketPath: string,
appPath: string,
args: LauncherCommandContext['args'],
runtimePluginPath?: string | null,
): Promise<void>;
}
@@ -44,7 +49,7 @@ export async function runMpvPostAppCommand(
context: LauncherCommandContext,
deps: MpvCommandDeps = defaultDeps,
): Promise<boolean> {
const { args, appPath, mpvSocketPath } = context;
const { args, appPath, scriptPath, mpvSocketPath } = context;
if (!args.mpvIdle) {
return false;
}
@@ -52,7 +57,12 @@ export async function runMpvPostAppCommand(
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
await deps.launchMpvIdleDetached(mpvSocketPath, appPath, args);
await deps.launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
);
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
if (!ready) {
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
+65 -6
View File
@@ -1,7 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import type { LauncherCommandContext } from './context.js';
import { runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
function createContext(): LauncherCommandContext {
return {
@@ -95,7 +97,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
};
let receivedStartMpvOptions: Record<string, unknown> | null = null;
const receivedStartMpvOptions: Record<string, unknown>[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
@@ -111,7 +113,9 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
_preloadedSubtitles,
options,
) => {
receivedStartMpvOptions = options ?? null;
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
@@ -130,8 +134,63 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
'startMpv',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
]);
assert.deepEqual(receivedStartMpvOptions, {
startPaused: true,
disableYoutubeSubtitleAutoLoad: true,
});
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
});
test('plugin auto-start playback marks background app for cleanup when mpv exits', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
};
const appPath = context.appPath ?? '';
state.appPath = appPath;
state.overlayManagedByLauncher = false;
const mpvProc = new EventEmitter() as EventEmitter & {
exitCode: number | null;
killed: boolean;
kill: () => boolean;
};
mpvProc.exitCode = null;
mpvProc.killed = false;
mpvProc.kill = () => true;
let cleanupSawManagedOverlay = false;
try {
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
setTimeout(() => {
mpvProc.exitCode = 0;
mpvProc.emit('exit', 0);
}, 5);
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {
throw new Error('startOverlay should not run when plugin auto-start is used');
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
},
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
});
assert.equal(cleanupSawManagedOverlay, true);
} finally {
state.appPath = '';
state.overlayManagedByLauncher = false;
}
});
+12 -10
View File
@@ -7,6 +7,8 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
import {
cleanupPlaybackSession,
launchAppCommandDetached,
markOverlayManagedByLauncher,
resolveLauncherRuntimePluginPath,
startMpv,
startOverlay,
state,
@@ -21,9 +23,8 @@ import {
getDefaultConfigDir,
getSetupStatePath,
readSetupState,
resolveDefaultMpvInstallPaths,
} from '../../src/shared/setup-state.js';
import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js';
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
@@ -107,14 +108,13 @@ 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);
},
hasLegacyMpvPlugin: () =>
detectInstalledFirstRunPluginCandidates({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: process.env.APPDATA,
}).length > 0,
launchSetupApp: () => {
const setupArgs = ['--background', '--setup'];
if (args.logLevel) {
@@ -237,6 +237,7 @@ export async function runPlaybackCommandWithDeps(
{
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
},
);
@@ -262,6 +263,7 @@ export async function runPlaybackCommandWithDeps(
: [],
);
} else if (pluginAutoStartEnabled) {
markOverlayManagedByLauncher(appPath);
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else {
+7 -1
View File
@@ -26,6 +26,7 @@ import {
runAppCommandCaptureOutput,
launchAppStartDetached,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
waitForUnixSocketReady,
} from './mpv.js';
@@ -1014,7 +1015,12 @@ export async function runJellyfinPlayMenu(
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
}
if (!mpvReady) {
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
await launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
);
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
}
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
+85
View File
@@ -17,6 +17,8 @@ import {
launchTexthookerOnly,
parseMpvArgString,
runAppCommandCaptureOutput,
resolveLauncherRuntimePluginPath,
resolveLauncherRuntimePluginPlan,
shouldResolveAniSkipMetadata,
stopOverlay,
startOverlay,
@@ -262,6 +264,89 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
});
});
test('resolveLauncherRuntimePluginPath finds bundled plugin from explicit environment path', () => {
const pluginDir = '/opt/SubMiner/plugin/subminer';
assert.equal(
resolveLauncherRuntimePluginPath({
appPath: '/opt/SubMiner/SubMiner.AppImage',
env: { SUBMINER_MPV_PLUGIN_PATH: pluginDir },
existsSync: (candidate) => candidate === path.join(pluginDir, 'main.lua'),
}),
path.join(pluginDir, 'main.lua'),
);
});
test('resolveLauncherRuntimePluginPath finds Linux app-support plugin assets', () => {
const homeDir = '/home/tester';
const expected = path.join(
homeDir,
'.local',
'share',
'SubMiner',
'plugin',
'subminer',
'main.lua',
);
assert.equal(
resolveLauncherRuntimePluginPath({
appPath: '/home/tester/.local/bin/SubMiner.AppImage',
scriptPath: '/home/tester/.local/bin/subminer',
platform: 'linux',
homeDir,
env: {},
existsSync: (candidate) => candidate === expected,
}),
expected,
);
});
test('resolveLauncherRuntimePluginPlan injects bundled plugin when no installed plugin exists', () => {
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: '/opt/SubMiner/plugin/subminer/main.lua',
platform: 'linux',
homeDir: '/home/tester',
existsSync: () => false,
});
assert.equal(plan.scriptPath, '/opt/SubMiner/plugin/subminer/main.lua');
assert.equal(plan.installedPlugin.installed, false);
assert.equal(plan.warningMessage, null);
assert.equal(plan.errorMessage, null);
});
test('resolveLauncherRuntimePluginPlan uses installed plugin instead of bundled injection', () => {
const installedPath = '/home/tester/.config/mpv/scripts/subminer/main.lua';
const versionPath = '/home/tester/.config/mpv/scripts/subminer/version.lua';
const existing = new Set([installedPath, versionPath]);
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: '/opt/SubMiner/plugin/subminer/main.lua',
platform: 'linux',
homeDir: '/home/tester',
existsSync: (candidate) => existing.has(candidate),
readFileSync: () => 'return { version = "0.12.0" }',
});
assert.equal(plan.scriptPath, null);
assert.equal(plan.installedPlugin.path, installedPath);
assert.equal(plan.installedPlugin.version, '0.12.0');
assert.match(plan.warningMessage ?? '', /This mpv session will use the installed plugin/);
assert.equal(plan.errorMessage, null);
});
test('resolveLauncherRuntimePluginPlan reports missing bundled plugin when no installed plugin exists', () => {
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: null,
platform: 'linux',
homeDir: '/home/tester',
existsSync: () => false,
});
assert.equal(plan.scriptPath, null);
assert.equal(plan.installedPlugin.installed, false);
assert.match(plan.errorMessage ?? '', /Packaged mpv plugin assets were not found/);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
+216 -2
View File
@@ -4,6 +4,10 @@ import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import {
detectInstalledMpvPlugin,
type InstalledMpvPluginDetection,
} from '../src/main/runtime/first-run-setup-plugin.js';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
@@ -42,6 +46,13 @@ const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
export interface LauncherRuntimePluginPlan {
scriptPath: string | null;
installedPlugin: InstalledMpvPluginDetection;
warningMessage: string | null;
errorMessage: string | null;
}
export function parseMpvArgString(input: string): string[] {
const chars = input;
const args: string[] = [];
@@ -226,6 +237,182 @@ export function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function normalizeRuntimePluginEntrypoint(
candidate: string,
deps: {
pathModule: typeof path;
existsSync: (candidate: string) => boolean;
},
): string | null {
const trimmed = candidate.trim();
if (!trimmed) return null;
if (trimmed.endsWith('.lua')) {
return deps.existsSync(trimmed) ? trimmed : null;
}
const entrypoint = deps.pathModule.join(trimmed, 'main.lua');
return deps.existsSync(entrypoint) ? entrypoint : null;
}
function pushMacAppRuntimePluginCandidate(
candidates: string[],
appPath: string,
pathModule: typeof path,
): void {
const appIndex = appPath.indexOf('.app');
if (appIndex < 0) return;
candidates.push(
pathModule.join(
appPath.slice(0, appIndex + '.app'.length),
'Contents',
'Resources',
'plugin',
'subminer',
),
);
}
export function resolveLauncherRuntimePluginPath(options: {
appPath: string;
scriptPath?: string;
platform?: NodeJS.Platform;
homeDir?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
dirname?: string;
pathModule?: typeof path;
existsSync?: (candidate: string) => boolean;
}): string | null {
const pathModule = options.pathModule ?? path;
const existsSync = options.existsSync ?? fs.existsSync;
const env = options.env ?? process.env;
const dirname = options.dirname ?? __dirname;
const cwd = options.cwd ?? process.cwd();
const platform = options.platform ?? process.platform;
const homeDir = options.homeDir ?? os.homedir();
const candidates: string[] = [];
if (env.SUBMINER_MPV_PLUGIN_PATH) {
candidates.push(env.SUBMINER_MPV_PLUGIN_PATH);
}
pushMacAppRuntimePluginCandidate(candidates, options.appPath, pathModule);
const appDir = pathModule.dirname(options.appPath);
candidates.push(
pathModule.join(appDir, 'resources', 'plugin', 'subminer'),
pathModule.join(appDir, 'plugin', 'subminer'),
);
if (options.scriptPath) {
const scriptDir = pathModule.dirname(realpathMaybe(options.scriptPath));
candidates.push(
pathModule.join(scriptDir, '..', 'share', 'SubMiner', 'plugin', 'subminer'),
pathModule.join(scriptDir, '..', 'lib', 'SubMiner', 'plugin', 'subminer'),
pathModule.join(scriptDir, 'plugin', 'subminer'),
);
}
if (platform === 'darwin') {
candidates.push(
pathModule.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'plugin', 'subminer'),
);
} else if (platform !== 'win32') {
candidates.push(
pathModule.join(
env.XDG_DATA_HOME?.trim() || pathModule.join(homeDir, '.local', 'share'),
'SubMiner',
'plugin',
'subminer',
),
);
}
candidates.push(
pathModule.join(cwd, 'plugin', 'subminer'),
pathModule.join(dirname, '..', 'plugin', 'subminer'),
pathModule.join(dirname, '..', '..', 'plugin', 'subminer'),
);
const seen = new Set<string>();
for (const candidate of candidates) {
const resolved = pathModule.resolve(candidate);
if (seen.has(resolved)) continue;
seen.add(resolved);
const entrypoint = normalizeRuntimePluginEntrypoint(resolved, { pathModule, existsSync });
if (entrypoint) {
return entrypoint;
}
}
return null;
}
export function resolveLauncherRuntimePluginPlan(options: {
runtimePluginPath?: string | null;
platform?: NodeJS.Platform;
homeDir?: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
existsSync?: (candidate: string) => boolean;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
}): LauncherRuntimePluginPlan {
const installedPlugin = detectInstalledMpvPlugin({
platform: options.platform ?? process.platform,
homeDir: options.homeDir ?? os.homedir(),
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
appDataDir: options.appDataDir ?? process.env.APPDATA,
mpvExecutablePath: options.mpvExecutablePath,
existsSync: options.existsSync,
readFileSync: options.readFileSync,
});
if (installedPlugin.installed) {
const versionText = installedPlugin.version
? `Detected plugin version: ${installedPlugin.version}.`
: 'Detected plugin version: unknown or legacy.';
return {
scriptPath: null,
installedPlugin,
warningMessage: `SubMiner detected an installed mpv plugin at: ${installedPlugin.path}. This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically. ${versionText}`,
errorMessage: null,
};
}
if (options.runtimePluginPath) {
return {
scriptPath: options.runtimePluginPath,
installedPlugin,
warningMessage: null,
errorMessage: null,
};
}
return {
scriptPath: null,
installedPlugin,
warningMessage: null,
errorMessage:
'Packaged mpv plugin assets were not found. Install the SubMiner assets bundle or set SUBMINER_MPV_PLUGIN_PATH to plugin/subminer/main.lua.',
};
}
function appendRuntimePluginLaunchArgs(
mpvArgs: string[],
plan: LauncherRuntimePluginPlan,
logLevel: LogLevel,
): void {
if (plan.warningMessage) {
log('warn', logLevel, plan.warningMessage);
}
if (plan.errorMessage) {
fail(plan.errorMessage);
}
if (plan.scriptPath) {
mpvArgs.push(`--script=${plan.scriptPath}`);
}
}
export function detectBackend(
backend: Backend,
env: NodeJS.ProcessEnv = process.env,
@@ -658,7 +845,11 @@ export async function startMpv(
socketPath: string,
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean },
options?: {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
runtimePluginPath?: string | null;
},
): Promise<void> {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
@@ -672,6 +863,14 @@ export async function startMpv(
const mpvArgs: string[] = [];
mpvArgs.push(...buildConfiguredMpvDefaultArgs(args));
appendRuntimePluginLaunchArgs(
mpvArgs,
resolveLauncherRuntimePluginPlan({
runtimePluginPath:
options?.runtimePluginPath ?? resolveLauncherRuntimePluginPath({ appPath }),
}),
args.logLevel,
);
if (targetKind === 'url' && isYoutubeTarget(target)) {
log('info', args.logLevel, 'Applying URL playback options');
mpvArgs.push('--ytdl=yes');
@@ -811,7 +1010,7 @@ export async function startOverlay(
env: buildAppEnv(),
});
attachAppProcessLogging(state.overlayProc);
state.overlayManagedByLauncher = true;
markOverlayManagedByLauncher(appPath);
const [socketReady] = await Promise.all([
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
@@ -831,6 +1030,13 @@ export async function startOverlay(
}
}
export function markOverlayManagedByLauncher(appPath?: string): void {
if (appPath) {
state.appPath = appPath;
}
state.overlayManagedByLauncher = true;
}
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
const target =
process.platform === 'darwin'
@@ -1236,6 +1442,7 @@ export function launchMpvIdleDetached(
socketPath: string,
appPath: string,
args: Args,
runtimePluginPath?: string | null,
): Promise<void> {
return (async () => {
await terminateTrackedDetachedMpv(args.logLevel);
@@ -1246,6 +1453,13 @@ export function launchMpvIdleDetached(
}
const mpvArgs: string[] = buildConfiguredMpvDefaultArgs(args);
appendRuntimePluginLaunchArgs(
mpvArgs,
resolveLauncherRuntimePluginPlan({
runtimePluginPath: runtimePluginPath ?? resolveLauncherRuntimePluginPath({ appPath }),
}),
args.logLevel,
);
if (args.mpvArgs) {
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
}
+63 -16
View File
@@ -116,34 +116,81 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
assert.deepEqual(calls, []);
});
test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => {
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
const calls: string[] = [];
let legacyPluginInstalled = true;
let reads = 0;
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,
readSetupState: () => {
reads += 1;
return {
version: 3,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
completionSource: 'user',
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
};
},
hasLegacyMpvPlugin: () => legacyPluginInstalled,
launchSetupApp: () => {
calls.push('launch');
legacyPluginInstalled = false;
},
sleep: async () => undefined,
now: () => 0,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(ready, true);
assert.deepEqual(calls, []);
assert.deepEqual(calls, ['launch']);
assert.equal(reads >= 3, true);
});
test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => {
const calls: string[] = [];
let reads = 0;
const ready = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
return {
version: 3,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 2,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
};
},
hasLegacyMpvPlugin: () => true,
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 () => {
+58 -8
View File
@@ -32,31 +32,81 @@ export async function waitForSetupCompletion(deps: {
return 'timeout';
}
export async function waitForLegacyMpvPluginPromptResolution(deps: {
readSetupState: () => SetupState | null;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
initialState?: SetupState | null;
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
const deadline = deps.now() + deps.timeoutMs;
const initialCompleted = isSetupCompleted(deps.initialState);
const initialCompletedAt = deps.initialState?.completedAt ?? null;
while (deps.now() <= deadline) {
const state = deps.readSetupState();
if (
isSetupCompleted(state) &&
(!initialCompleted || state?.completedAt !== initialCompletedAt)
) {
return 'acknowledged';
}
if (!initialCompleted && state?.status === 'cancelled') {
return 'cancelled';
}
await deps.sleep(deps.pollIntervalMs);
}
return 'timeout';
}
export async function ensureLauncherSetupReady(deps: {
readSetupState: () => SetupState | null;
isExternalYomitanConfigured?: () => boolean;
isPluginInstalled?: () => boolean;
hasLegacyMpvPlugin?: () => boolean;
launchSetupApp: () => void;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<boolean> {
const initialState = deps.readSetupState();
let setupLaunched = false;
const launchSetupApp = () => {
if (setupLaunched) return;
setupLaunched = true;
deps.launchSetupApp();
};
if (deps.hasLegacyMpvPlugin?.()) {
launchSetupApp();
const result = await waitForLegacyMpvPluginPromptResolution({
readSetupState: deps.readSetupState,
sleep: deps.sleep,
now: deps.now,
timeoutMs: deps.timeoutMs,
pollIntervalMs: deps.pollIntervalMs,
initialState,
});
if (result === 'cancelled' || result === 'timeout') {
return false;
}
}
if (deps.isExternalYomitanConfigured?.()) {
return true;
}
if (deps.isPluginInstalled?.()) {
return true;
}
const initialState = deps.readSetupState();
if (isSetupCompleted(initialState)) {
const stateAfterLegacyPrompt = deps.readSetupState();
if (isSetupCompleted(stateAfterLegacyPrompt)) {
return true;
}
deps.launchSetupApp();
launchSetupApp();
const result = await waitForSetupCompletion({
...deps,
ignoreInitialCancelledState: initialState?.status === 'cancelled',
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
});
return result === 'completed';
}