mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-13 08:12:54 -07:00
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:
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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("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.")) 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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user