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
This commit is contained in:
2026-05-12 19:40:26 -07:00
parent 430373f010
commit 75348aa72a
44 changed files with 2475 additions and 479 deletions
+148 -114
View File
@@ -5,8 +5,11 @@ import os from 'node:os';
import path from 'node:path';
import {
detectInstalledFirstRunPlugin,
installFirstRunPluginToDefaultLocation,
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedFirstRunPluginAssets,
resolvePackagedRuntimePluginPath,
syncInstalledFirstRunPluginBinaryPath,
} from './first-run-setup-plugin';
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
@@ -43,125 +46,22 @@ test('resolvePackagedFirstRunPluginAssets finds packaged plugin assets', () => {
});
});
test('installFirstRunPluginToDefaultLocation installs plugin and backs up existing files', () => {
test('resolvePackagedRuntimePluginPath returns packaged plugin entrypoint', () => {
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
const entrypoint = path.join(pluginRoot, 'subminer', 'main.lua');
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
fs.writeFileSync(entrypoint, '-- plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
fs.mkdirSync(path.dirname(installPaths.pluginEntrypointPath), { recursive: true });
fs.mkdirSync(installPaths.pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(path.join(installPaths.scriptsDir, 'subminer-loader.lua'), '-- old loader');
fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin');
fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n');
const result = installFirstRunPluginToDefaultLocation({
platform: 'linux',
homeDir,
xdgConfigHome,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\nbinary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\n',
);
const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir);
const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir);
assert.equal(
scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')),
true,
);
assert.equal(
scriptsDirEntries.some((entry) => entry.startsWith('subminer-loader.lua.bak.')),
true,
);
assert.equal(
scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')),
true,
);
});
});
test('installFirstRunPluginToDefaultLocation installs plugin to Windows mpv defaults', () => {
if (process.platform !== 'win32') {
return;
}
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\nbinary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\n',
);
});
});
test('installFirstRunPluginToDefaultLocation rewrites Windows plugin socket_path', () => {
if (process.platform !== 'win32') {
return;
}
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(
path.join(pluginRoot, 'subminer.conf'),
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(result.ok, true);
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
resolvePackagedRuntimePluginPath({
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
}),
entrypoint,
);
});
});
@@ -270,6 +170,140 @@ test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
});
});
test('detectInstalledFirstRunPluginCandidates returns all legacy autoload entries without script opts', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
const directoryInstall = installPaths.pluginDir;
const legacyScript = path.join(installPaths.scriptsDir, 'subminer.lua');
const legacyLoader = path.join(installPaths.scriptsDir, 'subminer-loader.lua');
fs.mkdirSync(directoryInstall, { recursive: true });
fs.writeFileSync(path.join(directoryInstall, 'main.lua'), '-- plugin');
fs.writeFileSync(legacyScript, '-- legacy plugin');
fs.writeFileSync(legacyLoader, '-- legacy loader');
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(installPaths.pluginConfigPath, 'socket_path=/tmp/subminer-socket\n');
const candidates = detectInstalledFirstRunPluginCandidates({
platform: 'linux',
homeDir,
xdgConfigHome,
});
assert.deepEqual(
candidates.map((candidate) => candidate.path).sort(),
[directoryInstall, legacyLoader, legacyScript].sort(),
);
assert.equal(
candidates.some((candidate) => candidate.path === installPaths.pluginConfigPath),
false,
);
});
});
test('detectInstalledFirstRunPluginCandidates includes Windows portable mpv scripts', () => {
withTempDir((root) => {
const homeDir = path.win32.join('C:\\Users', 'tester');
const appDataDir = path.win32.join(root, 'AppData', 'Roaming');
const mpvExecutablePath = path.win32.join(root, 'mpv', 'mpv.exe');
const portablePluginDir = path.win32.join(
path.win32.dirname(mpvExecutablePath),
'portable_config',
'scripts',
'subminer',
);
const portableLegacyScript = path.win32.join(
path.win32.dirname(mpvExecutablePath),
'portable_config',
'scripts',
'subminer.lua',
);
const existing = new Set([portablePluginDir, portableLegacyScript]);
const candidates = detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir,
appDataDir,
mpvExecutablePath,
existsSync: (candidate) => existing.has(candidate),
});
assert.deepEqual(
candidates.map((candidate) => candidate.path),
[portablePluginDir, portableLegacyScript],
);
});
});
test('detectInstalledMpvPlugin prefers Windows portable plugin and parses version', () => {
const homeDir = 'C:\\Users\\tester';
const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming';
const mpvExecutablePath = 'C:\\tools\\mpv\\mpv.exe';
const portableEntrypoint = 'C:\\tools\\mpv\\portable_config\\scripts\\subminer\\main.lua';
const portableVersion = 'C:\\tools\\mpv\\portable_config\\scripts\\subminer\\version.lua';
const appDataEntrypoint = 'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua';
const existing = new Set([portableEntrypoint, portableVersion, appDataEntrypoint]);
const detection = detectInstalledMpvPlugin({
platform: 'win32',
homeDir,
appDataDir,
mpvExecutablePath,
existsSync: (candidate) => existing.has(candidate),
readFileSync: (candidate) =>
candidate === portableVersion ? 'return { version = "0.12.0" }' : '',
});
assert.equal(detection.installed, true);
assert.equal(detection.path, portableEntrypoint);
assert.equal(detection.version, '0.12.0');
assert.equal(detection.source, 'portable-config');
});
test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without version', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const legacyPath = path.join(homeDir, '.config', 'mpv', 'scripts', 'subminer-loader.lua');
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, '-- legacy');
const detection = detectInstalledMpvPlugin({
platform: 'linux',
homeDir,
});
assert.equal(detection.installed, true);
assert.equal(detection.path, legacyPath);
assert.equal(detection.version, null);
assert.equal(detection.source, 'legacy-file');
});
});
test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => {
const calls: string[] = [];
const result = await removeLegacyMpvPluginCandidates({
candidates: [
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
],
trashItem: async (candidate) => {
calls.push(candidate);
if (candidate.endsWith('subminer.lua')) {
throw new Error('permission denied');
}
},
});
assert.deepEqual(calls, ['/tmp/mpv/scripts/subminer', '/tmp/mpv/scripts/subminer.lua']);
assert.equal(result.ok, false);
assert.deepEqual(result.removedPaths, ['/tmp/mpv/scripts/subminer']);
assert.deepEqual(result.failedPaths, [
{ path: '/tmp/mpv/scripts/subminer.lua', message: 'permission denied' },
]);
});
test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
+235 -54
View File
@@ -1,15 +1,30 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
import type { PluginInstallResult } from './first-run-setup-service';
function timestamp(): string {
return new Date().toISOString().replaceAll(':', '-');
export interface InstalledFirstRunPluginCandidate {
path: string;
kind: 'directory' | 'file';
}
function backupExistingPath(targetPath: string): void {
if (!fs.existsSync(targetPath)) return;
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
export type InstalledMpvPluginSource =
| 'default-config'
| 'xdg-config'
| 'portable-config'
| 'legacy-file';
export interface InstalledMpvPluginDetection {
installed: boolean;
path: string | null;
version: string | null;
source: InstalledMpvPluginSource | null;
message: string | null;
}
export interface LegacyMpvPluginRemovalResult {
ok: boolean;
removedPaths: string[];
failedPaths: Array<{ path: string; message: string }>;
}
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
@@ -89,6 +104,30 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
return null;
}
export function resolvePackagedRuntimePluginPath(deps: {
dirname: string;
appPath: string;
resourcesPath: string;
joinPath?: (...parts: string[]) => string;
existsSync?: (candidate: string) => boolean;
}): string | null {
const joinPath = deps.joinPath ?? path.join;
const existsSync = deps.existsSync ?? fs.existsSync;
const assets = resolvePackagedFirstRunPluginAssets({
dirname: deps.dirname,
appPath: deps.appPath,
resourcesPath: deps.resourcesPath,
joinPath,
existsSync,
});
if (!assets) {
return null;
}
const entrypoint = joinPath(assets.pluginDirSource, 'main.lua');
return existsSync(entrypoint) ? entrypoint : null;
}
export function detectInstalledFirstRunPlugin(
installPaths: MpvInstallPaths,
deps?: {
@@ -100,61 +139,203 @@ export function detectInstalledFirstRunPlugin(
return existsSync(pluginEntrypointPath);
}
export function installFirstRunPluginToDefaultLocation(options: {
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
interface MpvConfigRootCandidate {
root: string;
source: Exclude<InstalledMpvPluginSource, 'legacy-file'>;
}
function collectMpvConfigRootCandidates(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
dirname: string;
appPath: string;
resourcesPath: string;
binaryPath: string;
}): PluginInstallResult {
const installPaths = resolveDefaultMpvInstallPaths(
options.platform,
options.homeDir,
options.xdgConfigHome,
);
if (!installPaths.supported) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Automatic mpv plugin install is not supported on this platform yet.',
};
}
const assets = resolvePackagedFirstRunPluginAssets({
dirname: options.dirname,
appPath: options.appPath,
resourcesPath: options.resourcesPath,
});
if (!assets) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Packaged mpv plugin assets were not found.',
};
}
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
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 });
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
appDataDir?: string;
mpvExecutablePath?: string;
}): MpvConfigRootCandidate[] {
const platformPath = getPlatformPath(options.platform);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
const roots: MpvConfigRootCandidate[] = [];
if (options.mpvExecutablePath?.trim()) {
roots.push({
root: platformPath.join(
platformPath.dirname(options.mpvExecutablePath.trim()),
'portable_config',
),
source: 'portable-config',
});
}
roots.push({
root: platformPath.join(
options.appDataDir?.trim() || platformPath.join(options.homeDir, 'AppData', 'Roaming'),
'mpv',
),
source: 'default-config',
});
return roots;
}
const xdgRoot = options.xdgConfigHome?.trim()
? platformPath.join(options.xdgConfigHome.trim(), 'mpv')
: null;
const homeRoot = platformPath.join(options.homeDir, '.config', 'mpv');
const roots: MpvConfigRootCandidate[] = [];
if (xdgRoot) {
roots.push({ root: xdgRoot, source: 'xdg-config' });
}
if (!xdgRoot || xdgRoot !== homeRoot) {
roots.push({ root: homeRoot, source: 'default-config' });
}
return roots;
}
export function detectInstalledFirstRunPluginCandidates(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
existsSync?: (candidate: string) => boolean;
}): InstalledFirstRunPluginCandidate[] {
const platformPath = getPlatformPath(options.platform);
const existsSync = options.existsSync ?? fs.existsSync;
const roots = collectMpvConfigRootCandidates(options);
const candidates: InstalledFirstRunPluginCandidate[] = [];
const seen = new Set<string>();
const pushIfExists = (
candidate: InstalledFirstRunPluginCandidate,
verifyPath = candidate.path,
) => {
if (seen.has(candidate.path) || !existsSync(verifyPath)) return;
seen.add(candidate.path);
candidates.push(candidate);
};
for (const root of roots) {
const scriptsDir = platformPath.join(root.root, 'scripts');
const pluginDir = platformPath.join(scriptsDir, 'subminer');
pushIfExists({ path: pluginDir, kind: 'directory' });
pushIfExists({ path: platformPath.join(scriptsDir, 'subminer.lua'), kind: 'file' });
pushIfExists({ path: platformPath.join(scriptsDir, 'subminer-loader.lua'), kind: 'file' });
}
return candidates;
}
function parseInstalledPluginVersion(content: string): string | null {
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
return match?.[1] ?? null;
}
function readInstalledPluginVersion(options: {
pluginEntrypointPath: string;
platformPath: typeof path.posix | typeof path.win32;
existsSync: (candidate: string) => boolean;
readFileSync: (candidate: string, encoding: BufferEncoding) => string;
}): string | null {
const versionPath = options.platformPath.join(
options.platformPath.dirname(options.pluginEntrypointPath),
'version.lua',
);
if (!options.existsSync(versionPath)) {
return null;
}
try {
return parseInstalledPluginVersion(options.readFileSync(versionPath, 'utf8'));
} catch {
return null;
}
}
export function detectInstalledMpvPlugin(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
existsSync?: (candidate: string) => boolean;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
}): InstalledMpvPluginDetection {
const platformPath = getPlatformPath(options.platform);
const existsSync = options.existsSync ?? fs.existsSync;
const readFileSync =
options.readFileSync ?? ((candidate, encoding) => fs.readFileSync(candidate, encoding));
const roots = collectMpvConfigRootCandidates(options);
for (const root of roots) {
const scriptsDir = platformPath.join(root.root, 'scripts');
const directoryEntrypoint = platformPath.join(scriptsDir, 'subminer', 'main.lua');
if (existsSync(directoryEntrypoint)) {
const version = readInstalledPluginVersion({
pluginEntrypointPath: directoryEntrypoint,
platformPath,
existsSync,
readFileSync,
});
return {
installed: true,
path: directoryEntrypoint,
version,
source: root.source,
message: `SubMiner detected an installed mpv plugin at: ${directoryEntrypoint}`,
};
}
for (const legacyPath of [
platformPath.join(scriptsDir, 'subminer.lua'),
platformPath.join(scriptsDir, 'subminer-loader.lua'),
]) {
if (existsSync(legacyPath)) {
return {
installed: true,
path: legacyPath,
version: null,
source: root.source === 'portable-config' ? 'portable-config' : 'legacy-file',
message: `SubMiner detected an installed mpv plugin at: ${legacyPath}`,
};
}
}
}
return {
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
installed: false,
path: null,
version: null,
source: null,
message: null,
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function removeLegacyMpvPluginCandidates(options: {
candidates: InstalledFirstRunPluginCandidate[];
trashItem: (path: string) => Promise<void>;
}): Promise<LegacyMpvPluginRemovalResult> {
const removedPaths: string[] = [];
const failedPaths: Array<{ path: string; message: string }> = [];
const seen = new Set<string>();
for (const candidate of options.candidates) {
if (seen.has(candidate.path)) continue;
seen.add(candidate.path);
try {
await options.trashItem(candidate.path);
removedPaths.push(candidate.path);
} catch (error) {
failedPaths.push({ path: candidate.path, message: errorMessage(error) });
}
}
return {
ok: failedPaths.length === 0,
removedPaths,
failedPaths,
};
}
+117 -11
View File
@@ -159,18 +159,17 @@ test('setup service auto-completes legacy installs with config and dictionaries'
});
});
test('setup service requires mpv plugin install before finish', async () => {
test('setup service allows finish without global mpv plugin once dictionaries are ready', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let dictionaryCount = 0;
let pluginInstalled = false;
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => dictionaryCount,
detectPluginInstalled: () => pluginInstalled,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -184,11 +183,6 @@ test('setup service requires mpv plugin install before finish', async () => {
assert.equal(initial.state.status, 'incomplete');
assert.equal(initial.canFinish, false);
const installed = await service.installMpvPlugin();
assert.equal(installed.state.pluginInstallStatus, 'installed');
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
pluginInstalled = true;
dictionaryCount = 1;
const refreshed = await service.refreshStatus();
assert.equal(refreshed.canFinish, true);
@@ -304,7 +298,7 @@ test('setup service reopens when external-yomitan completion later has no extern
});
});
test('setup service reopens when a completed setup no longer has the mpv plugin installed', async () => {
test('setup service keeps completed when a global mpv plugin is removed later', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
@@ -340,12 +334,41 @@ test('setup service reopens when a completed setup no longer has the mpv plugin
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'incomplete');
assert.equal(snapshot.canFinish, false);
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.canFinish, true);
assert.equal(snapshot.pluginStatus, 'required');
});
});
test('setup service reopens completed setup as in-progress when legacy mpv plugin removal is needed', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
detectPluginInstalled: () => true,
detectLegacyMpvPluginCandidates: () => [
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
],
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupCompleted();
const inProgress = await service.markSetupInProgress();
assert.equal(inProgress.state.status, 'in_progress');
assert.equal(inProgress.state.completedAt, null);
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.notEqual(completed.state.completedAt, null);
});
});
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
@@ -490,3 +513,86 @@ test('setup service persists Windows mpv shortcut preferences and status with on
assert.deepEqual(stateChanges, ['installed']);
});
});
test('setup service removes legacy mpv plugin candidates and refreshes detection', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let legacyCandidates = [{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' as const }];
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => legacyCandidates.length > 0,
detectLegacyMpvPluginCandidates: () => legacyCandidates,
removeLegacyMpvPlugins: async (candidates) => {
assert.deepEqual(candidates, legacyCandidates);
legacyCandidates = [];
return {
ok: true,
removedPaths: ['/tmp/mpv/scripts/subminer'],
failedPaths: [],
};
},
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const before = await service.refreshStatus();
assert.deepEqual(before.legacyMpvPluginPaths, ['/tmp/mpv/scripts/subminer']);
const removed = await service.removeLegacyMpvPlugin();
assert.equal(
removed.message,
'Legacy mpv plugin removed. Regular mpv will no longer load SubMiner. SubMiner-managed playback will use the bundled runtime plugin.',
);
assert.deepEqual(removed.legacyMpvPluginPaths, []);
});
});
test('setup service reports failed legacy mpv plugin trash paths', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const legacyCandidates = [
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' as const },
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' as const },
];
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => true,
detectLegacyMpvPluginCandidates: () => legacyCandidates,
removeLegacyMpvPlugins: async () => ({
ok: false,
removedPaths: ['/tmp/mpv/scripts/subminer'],
failedPaths: [{ path: '/tmp/mpv/scripts/subminer.lua', message: 'permission denied' }],
}),
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const removed = await service.removeLegacyMpvPlugin();
assert.equal(
removed.message,
'Removed 1 legacy mpv plugin path, but failed to remove: /tmp/mpv/scripts/subminer.lua (permission denied). Delete the failed paths manually from mpv scripts.',
);
assert.deepEqual(removed.legacyMpvPluginPaths, [
'/tmp/mpv/scripts/subminer',
'/tmp/mpv/scripts/subminer.lua',
]);
});
});
+66 -30
View File
@@ -11,6 +11,10 @@ import {
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
import type {
InstalledFirstRunPluginCandidate,
LegacyMpvPluginRemovalResult,
} from './first-run-setup-plugin';
export interface SetupWindowsMpvShortcutSnapshot {
supported: boolean;
@@ -29,6 +33,7 @@ export interface SetupStatusSnapshot {
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
legacyMpvPluginPaths: string[];
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
message: string | null;
state: SetupState;
@@ -48,7 +53,7 @@ export interface FirstRunSetupService {
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
removeLegacyMpvPlugin: () => Promise<SetupStatusSnapshot>;
configureWindowsMpvShortcuts: (preferences: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
@@ -176,9 +181,6 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
if (!snapshot.configReady) {
return 'Create or provide the config file before finishing setup.';
}
if (snapshot.pluginStatus !== 'installed') {
return 'Install the mpv plugin before finishing setup.';
}
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
return 'Install at least one Yomitan dictionary before finishing setup.';
}
@@ -219,7 +221,13 @@ export function createFirstRunSetupService(deps: {
getYomitanDictionaryCount: () => Promise<number>;
isExternalYomitanConfigured?: () => boolean;
detectPluginInstalled: () => boolean | Promise<boolean>;
installPlugin: () => Promise<PluginInstallResult>;
detectLegacyMpvPluginCandidates?: () =>
| InstalledFirstRunPluginCandidate[]
| Promise<InstalledFirstRunPluginCandidate[]>;
installPlugin?: () => Promise<PluginInstallResult>;
removeLegacyMpvPlugins?: (
candidates: InstalledFirstRunPluginCandidate[],
) => Promise<LegacyMpvPluginRemovalResult>;
detectWindowsMpvShortcuts?: () =>
| { startMenuInstalled: boolean; desktopInstalled: boolean }
| Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>;
@@ -250,6 +258,7 @@ export function createFirstRunSetupService(deps: {
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled();
const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.()
: undefined;
@@ -264,16 +273,15 @@ export function createFirstRunSetupService(deps: {
return {
configReady,
dictionaryCount,
canFinish:
pluginInstalled &&
isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
}),
canFinish: isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
}),
externalYomitanConfigured,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
legacyMpvPluginPaths: legacyMpvPluginCandidates.map((candidate) => candidate.path),
windowsMpvShortcuts: {
supported: isWindows,
startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled,
@@ -308,14 +316,11 @@ export function createFirstRunSetupService(deps: {
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled();
const canFinish =
pluginInstalled &&
isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
});
const canFinish = isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
});
if (isSetupCompleted(state) && canFinish) {
completed = true;
return refreshWithState(state);
@@ -349,8 +354,20 @@ export function createFirstRunSetupService(deps: {
markSetupInProgress: async () => {
const state = readState();
if (state.status === 'completed') {
completed = true;
return refreshWithState(state);
const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
if (legacyMpvPluginCandidates.length === 0) {
completed = true;
return refreshWithState(state);
}
completed = false;
return refreshWithState(
writeState({
...state,
status: 'in_progress',
completedAt: null,
completionSource: null,
}),
);
}
return refreshWithState(writeState({ ...state, status: 'in_progress' }));
},
@@ -379,15 +396,34 @@ export function createFirstRunSetupService(deps: {
}),
);
},
installMpvPlugin: async () => {
const result = await deps.installPlugin();
removeLegacyMpvPlugin: async () => {
const candidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
if (candidates.length === 0) {
return refreshWithState(readState(), 'No legacy mpv plugin files were found.');
}
if (!deps.removeLegacyMpvPlugins) {
return refreshWithState(
readState(),
'Legacy mpv plugin removal is unavailable in this runtime.',
);
}
const result = await deps.removeLegacyMpvPlugins(candidates);
if (result.ok) {
return refreshWithState(
readState(),
'Legacy mpv plugin removed. Regular mpv will no longer load SubMiner. SubMiner-managed playback will use the bundled runtime plugin.',
);
}
const removedCount = result.removedPaths.length;
const removedText = `${removedCount} legacy mpv plugin path${removedCount === 1 ? '' : 's'}`;
const failedText = result.failedPaths
.map((failure) => `${failure.path} (${failure.message})`)
.join(', ');
return refreshWithState(
writeState({
...readState(),
pluginInstallStatus: result.pluginInstallStatus,
pluginInstallPathSummary: result.pluginInstallPathSummary,
}),
result.message,
readState(),
`Removed ${removedText}, but failed to remove: ${failedText}. Delete the failed paths manually from mpv scripts.`,
);
},
configureWindowsMpvShortcuts: async (preferences) => {
@@ -30,8 +30,11 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
});
assert.match(html, /SubMiner setup/);
assert.match(html, /Install mpv plugin/);
assert.match(html, /Required before SubMiner setup can finish/);
assert.doesNotMatch(html, /Install legacy mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.match(html, /Ready/);
assert.doesNotMatch(html, /Bundled ready/);
assert.match(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
@@ -58,14 +61,49 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
message: null,
});
assert.match(html, /Reinstall mpv plugin/);
assert.doesNotMatch(html, /Reinstall mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.match(html, /mpv executable path/);
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
assert.match(html, /aria-label="Path to mpv\.exe"/);
assert.match(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
});
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
legacyMpvPluginPaths: ['/tmp/mpv/scripts/subminer', '/tmp/mpv/scripts/subminer.lua'],
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
});
assert.match(html, /Legacy mpv plugin/);
assert.match(html, /Legacy detected/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
assert.match(html, /Remove legacy mpv plugin/);
assert.match(html, /class="legacy-remove"/);
assert.match(html, /\.legacy-remove/);
assert.match(html, /Continue without removing/);
assert.match(
html,
/Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary\./,
/Remove these SubMiner mpv plugin files from mpv.s scripts directory\? This stops regular mpv from loading SubMiner\./,
);
assert.match(html, /action=remove-legacy-plugin/);
});
test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', () => {
@@ -158,6 +196,12 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
});
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=remove-legacy-plugin'),
{
action: 'remove-legacy-plugin',
},
);
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null,
@@ -177,7 +221,7 @@ test('first-run setup window handler focuses existing window', () => {
assert.deepEqual(calls, ['focus']);
});
test('first-run setup navigation handler prevents default and dispatches action', async () => {
test('first-run setup navigation handler prevents default and dispatches supported action', async () => {
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
@@ -188,13 +232,20 @@ test('first-run setup navigation handler prevents default and dispatches action'
});
const prevented = handleNavigation({
url: 'subminer://first-run-setup?action=install-plugin',
url: 'subminer://first-run-setup?action=refresh',
preventDefault: () => calls.push('preventDefault'),
});
assert.equal(prevented, true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
assert.deepEqual(calls, ['preventDefault', 'refresh']);
});
test('first-run setup parser rejects legacy global plugin install action', () => {
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=install-plugin'),
null,
);
});
test('first-run setup navigation handler swallows stale custom-scheme actions', () => {
+54 -18
View File
@@ -18,7 +18,7 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
export type FirstRunSetupAction =
| 'configure-mpv-executable-path'
| 'install-plugin'
| 'remove-legacy-plugin'
| 'configure-windows-mpv-shortcuts'
| 'open-yomitan-settings'
| 'refresh'
@@ -38,6 +38,7 @@ export interface FirstRunSetupHtmlModel {
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
legacyMpvPluginPaths?: string[];
mpvExecutablePath: string;
mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
windowsMpvShortcuts: {
@@ -64,20 +65,19 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
}
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
const pluginActionLabel =
model.pluginStatus === 'installed' ? 'Reinstall mpv plugin' : 'Install mpv plugin';
const legacyMpvPluginPaths = model.legacyMpvPluginPaths ?? [];
const finishButtonLabel =
legacyMpvPluginPaths.length > 0 && model.canFinish
? 'Continue without removing'
: 'Finish setup';
const pluginLabel =
model.pluginStatus === 'installed'
? 'Installed'
legacyMpvPluginPaths.length > 0
? 'Legacy detected'
: model.pluginStatus === 'failed'
? 'Failed'
: 'Required';
: 'Ready';
const pluginTone =
model.pluginStatus === 'installed'
? 'ready'
: model.pluginStatus === 'failed'
? 'danger'
: 'warn';
legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready';
const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed'
? 'Installed'
@@ -159,6 +159,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</form>
</div>`
: '';
const legacyPluginCard =
legacyMpvPluginPaths.length > 0
? `
<div class="card block">
<div class="card-head">
<div>
<strong>Legacy mpv plugin</strong>
<div class="meta">Regular mpv still loads SubMiner from these mpv scripts paths.</div>
</div>
${renderStatusBadge('Found', 'warn')}
</div>
<ul class="legacy-paths">
${legacyMpvPluginPaths.map((pluginPath) => `<li>${escapeHtml(pluginPath)}</li>`).join('')}
</ul>
<button class="legacy-remove" onclick="if (confirm(&quot;Remove these SubMiner mpv plugin files from mpv's scripts directory? This stops regular mpv from loading SubMiner. SubMiner-managed playback will keep working with the bundled runtime plugin.&quot;)) window.location.href='subminer://first-run-setup?action=remove-legacy-plugin'">Remove legacy mpv plugin</button>
</div>`
: '';
const yomitanMeta = model.externalYomitanConfigured
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
@@ -179,8 +196,8 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.canFinish
? model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary.'
: 'Finish stays locked until the mpv plugin is installed and Yomitan reports at least one installed dictionary.';
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary. SubMiner-managed mpv launches use the bundled runtime plugin.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
return `<!doctype html>
<html>
@@ -307,6 +324,18 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
background: transparent;
border: 1px solid rgba(202, 211, 245, 0.12);
}
button.legacy-remove {
display: inline-flex;
justify-content: center;
align-items: center;
min-width: 220px;
border: 1px solid rgba(237, 135, 150, 0.38);
background: rgba(237, 135, 150, 0.14);
color: #f5b1ba;
}
button.legacy-remove:hover {
background: rgba(237, 135, 150, 0.22);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
@@ -321,6 +350,13 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
color: var(--muted);
font-size: 12px;
}
.legacy-paths {
margin: 10px 0 12px;
padding-left: 18px;
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
@@ -335,9 +371,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
<div class="card">
<div>
<strong>mpv plugin</strong>
<strong>mpv runtime plugin</strong>
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
<div class="meta">Required before SubMiner setup can finish.</div>
<div class="meta">Managed mpv launches use the bundled runtime plugin.</div>
</div>
${renderStatusBadge(pluginLabel, pluginTone)}
</div>
@@ -350,11 +386,11 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
${mpvExecutablePathCard}
${windowsShortcutCard}
${legacyPluginCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
<div class="footer">${escapeHtml(footerMessage)}</div>
@@ -371,7 +407,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
const action = parsed.searchParams.get('action');
if (
action !== 'configure-mpv-executable-path' &&
action !== 'install-plugin' &&
action !== 'remove-legacy-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
@@ -17,8 +17,8 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', ()
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
assert.deepEqual(options, {
width: 480,
height: 460,
width: 560,
height: 640,
title: 'SubMiner Setup',
show: true,
autoHideMenuBar: true,
+2 -2
View File
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return createSetupWindowHandler(deps, {
width: 480,
height: 460,
width: 560,
height: 640,
title: 'SubMiner Setup',
resizable: false,
minimizable: false,
@@ -230,6 +230,104 @@ test('launchWindowsMpv spawns detached mpv with targets', async () => {
]);
});
test('launchWindowsMpv skips bundled script when installed plugin is detected', async () => {
const calls: string[] = [];
const notifications: string[] = [];
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: async (command, args) => {
calls.push(command);
calls.push(args.join('|'));
},
}),
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'',
'normal',
{
detectInstalledMpvPlugin: () => ({
installed: true,
path: 'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
version: null,
source: 'default-config',
message: null,
}),
notifyInstalledPluginDetected: (detection) => {
notifications.push(detection.path ?? '');
},
},
);
assert.equal(result.ok, true);
assert.equal(calls[0], 'C:\\mpv\\mpv.exe');
assert.doesNotMatch(calls[1] ?? '', /--script=C:\\Program Files\\SubMiner/);
assert.match(calls[1] ?? '', /--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner\.exe/);
assert.deepEqual(notifications, [
'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
]);
});
test('launchWindowsMpv prompts before launch and injects bundled script after legacy plugin removal', async () => {
const calls: string[] = [];
const prompts: string[] = [];
let detectCalls = 0;
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: async (command, args) => {
calls.push(command);
calls.push(args.join('|'));
},
}),
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'',
'normal',
{
detectInstalledMpvPlugin: () => {
detectCalls += 1;
return detectCalls === 1
? {
installed: true,
path: 'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
version: '0.12.0',
source: 'default-config',
message: null,
}
: {
installed: false,
path: null,
version: null,
source: null,
message: null,
};
},
resolveInstalledPluginBeforeLaunch: async (detection) => {
prompts.push(detection.path ?? '');
return 'removed' as const;
},
},
);
assert.equal(result.ok, true);
assert.equal(detectCalls, 2);
assert.deepEqual(prompts, [
'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
]);
assert.equal(calls[0], 'C:\\mpv\\mpv.exe');
assert.match(
calls[1] ?? '',
/--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main\.lua/,
);
});
test('launchWindowsMpv reports spawn failures with path context', async () => {
const errors: string[] = [];
const result = await launchWindowsMpv(
+43 -3
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import type { MpvLaunchMode } from '../../types/config';
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
@@ -13,6 +14,15 @@ export interface WindowsMpvLaunchDeps {
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
export interface WindowsMpvRuntimePluginPolicy {
detectInstalledMpvPlugin?: (mpvPath: string) => InstalledMpvPluginDetection;
notifyInstalledPluginDetected?: (detection: InstalledMpvPluginDetection) => void;
resolveInstalledPluginBeforeLaunch?: (
detection: InstalledMpvPluginDetection,
mpvPath: string,
) => Promise<'removed' | 'continue' | 'cancel'> | 'removed' | 'continue' | 'cancel';
}
function normalizeCandidate(candidate: string | undefined): string {
return typeof candidate === 'string' ? candidate.trim() : '';
}
@@ -100,10 +110,12 @@ export function buildWindowsMpvLaunchArgs(
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
? `--script=${pluginEntrypointPath.trim()}`
: null;
const scriptOptPairs = scriptEntrypoint
const hasBinaryPath = typeof binaryPath === 'string' && binaryPath.trim().length > 0;
const shouldPassSubminerScriptOpts = scriptEntrypoint || hasBinaryPath;
const scriptOptPairs = shouldPassSubminerScriptOpts
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
: [];
if (scriptEntrypoint && typeof binaryPath === 'string' && binaryPath.trim().length > 0) {
if (hasBinaryPath) {
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
}
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
@@ -136,6 +148,7 @@ export async function launchWindowsMpv(
pluginEntrypointPath?: string,
configuredMpvPath?: string,
launchMode: MpvLaunchMode = 'normal',
runtimePluginPolicy?: WindowsMpvRuntimePluginPolicy,
): Promise<{ ok: boolean; mpvPath: string }> {
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
@@ -150,9 +163,36 @@ export async function launchWindowsMpv(
}
try {
let installedPlugin = runtimePluginPolicy?.detectInstalledMpvPlugin?.(mpvPath);
let installedPluginPrompted = false;
if (installedPlugin?.installed) {
const resolution = await runtimePluginPolicy?.resolveInstalledPluginBeforeLaunch?.(
installedPlugin,
mpvPath,
);
installedPluginPrompted = resolution != null;
if (resolution === 'cancel') {
return { ok: false, mpvPath };
}
if (resolution === 'removed') {
installedPlugin = runtimePluginPolicy?.detectInstalledMpvPlugin?.(mpvPath);
}
}
const runtimePluginEntrypointPath = installedPlugin?.installed
? undefined
: pluginEntrypointPath;
if (installedPlugin?.installed && !installedPluginPrompted) {
runtimePluginPolicy?.notifyInstalledPluginDetected?.(installedPlugin);
}
await deps.spawnDetached(
mpvPath,
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath, launchMode),
buildWindowsMpvLaunchArgs(
targets,
extraArgs,
binaryPath,
runtimePluginEntrypointPath,
launchMode,
),
);
return { ok: true, mpvPath };
} catch (error) {