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

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

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

* harden bootstrap version load and clean plugin on uninstall

- Use pcall for version.lua in bootstrap.lua so missing version module does not crash plugin startup
- Remove plugin/subminer from app-data dirs in uninstall-linux and uninstall-macos targets
- Add Lua compat test asserting bootstrap uses defensive pcall for version load
- Add release-workflow test asserting uninstall targets clean bundled plugin dirs
- Delete completed planning document
This commit is contained in:
2026-05-12 23:11:19 -07:00
committed by GitHub
parent e5c1135501
commit 7c9b65db8b
43 changed files with 2116 additions and 481 deletions
@@ -1025,6 +1025,46 @@ describe('stats server API routes', () => {
assert.equal(res.status, 400);
});
it('GET /api/stats/anilist/search uses the configured AniList rate limiter', async () => {
const originalFetch = globalThis.fetch;
let acquireCalls = 0;
let recordCalls = 0;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
data: {
Page: {
media: [{ id: 21858, title: { romaji: 'Little Witch Academia' } }],
},
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
},
)) as typeof fetch;
try {
const app = createStatsApp(createMockTracker(), {
anilistRateLimiter: {
acquire: async () => {
acquireCalls += 1;
},
recordResponse: () => {
recordCalls += 1;
},
},
});
const res = await app.request('/api/stats/anilist/search?q=Little%20Witch%20Academia');
assert.equal(res.status, 200);
assert.equal(acquireCalls, 1);
assert.equal(recordCalls, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => {
const originalFetch = globalThis.fetch;
const requests: unknown[] = [];
@@ -184,6 +184,57 @@ test('updateAnilistPostWatchProgress updates progress when behind', async () =>
}
});
test('updateAnilistPostWatchProgress uses the configured AniList rate limiter', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
let acquireCalls = 0;
let recordCalls = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 11, episodes: 24, title: { english: 'Demo Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: {
id: 11,
mediaListEntry: { progress: 2, status: 'CURRENT' },
},
},
});
}
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 3, status: 'CURRENT' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3, {
rateLimiter: {
acquire: async () => {
acquireCalls += 1;
},
recordResponse: () => {
recordCalls += 1;
},
},
});
assert.equal(result.status, 'updated');
assert.equal(acquireCalls, 3);
assert.equal(recordCalls, 3);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
@@ -2,6 +2,7 @@ import * as childProcess from 'child_process';
import * as path from 'path';
import { parseMediaInfo } from '../../../jimaku/utils';
import type { AnilistRateLimiter } from './rate-limiter';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
@@ -19,6 +20,10 @@ export interface AnilistPostWatchUpdateResult {
message: string;
}
export interface AnilistPostWatchUpdateOptions {
rateLimiter?: AnilistRateLimiter;
}
interface AnilistGraphQlError {
message?: string;
}
@@ -155,8 +160,10 @@ async function anilistGraphQl<T>(
accessToken: string,
query: string,
variables: Record<string, unknown>,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistGraphQlResponse<T>> {
try {
await options.rateLimiter?.acquire();
const response = await fetch(ANILIST_GRAPHQL_URL, {
method: 'POST',
headers: {
@@ -166,6 +173,7 @@ async function anilistGraphQl<T>(
body: JSON.stringify({ query, variables }),
});
options.rateLimiter?.recordResponse(response.headers);
const payload = (await response.json()) as AnilistGraphQlResponse<T>;
return payload;
} catch (error) {
@@ -269,6 +277,7 @@ export async function updateAnilistPostWatchProgress(
accessToken: string,
title: string,
episode: number,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistPostWatchUpdateResult> {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
@@ -288,6 +297,7 @@ export async function updateAnilistPostWatchProgress(
}
`,
{ search: title },
options,
);
const searchError = firstErrorMessage(searchResponse);
if (searchError) {
@@ -317,6 +327,7 @@ export async function updateAnilistPostWatchProgress(
}
`,
{ mediaId: picked.id },
options,
);
const entryError = firstErrorMessage(entryResponse);
if (entryError) {
@@ -345,6 +356,7 @@ export async function updateAnilistPostWatchProgress(
}
`,
{ mediaId: picked.id, progress: episode },
options,
);
const saveError = firstErrorMessage(saveResponse);
if (saveError) {
@@ -5,7 +5,12 @@ import path from 'node:path';
import test from 'node:test';
import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js';
import { Database } from '../immersion-tracker/sqlite.js';
import { ensureSchema, getOrCreateVideoRecord } from '../immersion-tracker/storage.js';
import {
ensureSchema,
getOrCreateAnimeRecord,
getOrCreateVideoRecord,
linkVideoToAnimeRecord,
} from '../immersion-tracker/storage.js';
import { getCoverArt } from '../immersion-tracker/query-library.js';
import { upsertCoverArt } from '../immersion-tracker/query-maintenance.js';
import { SOURCE_TYPE_LOCAL } from '../immersion-tracker/types.js';
@@ -100,6 +105,82 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
}
});
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const firstVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-1.mkv', {
canonicalTitle: 'Shared Cover Show',
sourcePath: '/tmp/cover-fetcher-cache-1.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const secondVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-2.mkv', {
canonicalTitle: 'Shared Cover Show',
sourcePath: '/tmp/cover-fetcher-cache-2.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Shared Cover Show',
canonicalTitle: 'Shared Cover Show',
anilistId: 99,
titleRomaji: 'Shared Cover Show',
titleEnglish: 'Shared Cover Show',
titleNative: null,
metadataJson: null,
});
for (const videoId of [firstVideoId, secondVideoId]) {
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: 'Shared Cover Show',
parsedSeason: 1,
parsedEpisode: videoId,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: null,
});
}
upsertCoverArt(db, firstVideoId, {
anilistId: 99,
coverUrl: 'https://images.test/shared-cover.jpg',
coverBlob: Buffer.from([9, 8, 7, 6]),
titleRomaji: 'Shared Cover Show',
titleEnglish: 'Shared Cover Show',
episodesTotal: 12,
});
let fetchCalls = 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
fetchCalls += 1;
throw new Error('unexpected AniList or image request');
}) as typeof fetch;
try {
const fetcher = createCoverArtFetcher(
{
acquire: async () => {},
recordResponse: () => {},
},
console,
);
const fetched = await fetcher.fetchIfMissing(db, secondVideoId, 'Shared Cover Show');
const stored = getCoverArt(db, secondVideoId);
assert.equal(fetched, true);
assert.equal(fetchCalls, 0);
assert.equal(stored?.anilistId, 99);
assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '09080706');
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
function createJsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
+34 -1
View File
@@ -1,6 +1,11 @@
import type { AnilistRateLimiter } from './rate-limiter';
import type { DatabaseSync } from '../immersion-tracker/sqlite';
import { getCoverArt, upsertCoverArt, updateAnimeAnilistInfo } from '../immersion-tracker/query';
import {
getAnimeCoverArt,
getCoverArt,
upsertCoverArt,
updateAnimeAnilistInfo,
} from '../immersion-tracker/query';
import {
guessAnilistMediaInfo,
runGuessit,
@@ -257,6 +262,30 @@ export function createCoverArtFetcher(
logger: Logger,
options: CoverArtFetcherOptions = {},
): CoverArtFetcher {
const reuseAnimeCoverArt = (db: DatabaseSync, videoId: number): boolean => {
const row = db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | undefined;
if (!row?.animeId) {
return false;
}
const shared = getAnimeCoverArt(db, row.animeId);
if (!shared?.coverBlob) {
return false;
}
upsertCoverArt(db, videoId, {
anilistId: shared.anilistId,
coverUrl: shared.coverUrl,
coverBlob: shared.coverBlob,
titleRomaji: shared.titleRomaji,
titleEnglish: shared.titleEnglish,
episodesTotal: shared.episodesTotal,
});
return true;
};
const resolveCanonicalTitle = (
db: DatabaseSync,
videoId: number,
@@ -317,6 +346,10 @@ export function createCoverArtFetcher(
}
}
if (reuseAnimeCoverArt(db, videoId)) {
return true;
}
if (
existing &&
existing.coverUrl === null &&
+9
View File
@@ -14,6 +14,7 @@ import {
getPreferredNoteFieldValue,
} from '../../anki-field-config.js';
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
type StatsServerNoteInfo = {
noteId: number;
@@ -255,6 +256,7 @@ export interface StatsServerConfig {
knownWordCachePath?: string;
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
}
@@ -338,6 +340,7 @@ export function createStatsApp(
knownWordCachePath?: string;
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
},
@@ -632,6 +635,7 @@ export function createStatsApp(
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json([]);
try {
await options?.anilistRateLimiter?.acquire();
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -652,6 +656,10 @@ export function createStatsApp(
variables: { search: query },
}),
});
options?.anilistRateLimiter?.recordResponse(res.headers);
if (res.status === 429) {
return c.json([]);
}
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
return c.json(json.data?.Page?.media ?? []);
} catch {
@@ -1131,6 +1139,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
knownWordCachePath: config.knownWordCachePath,
mpvSocketPath: config.mpvSocketPath,
ankiConnectConfig: config.ankiConnectConfig,
anilistRateLimiter: config.anilistRateLimiter,
addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId,
});
+105 -9
View File
@@ -1,6 +1,7 @@
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app, dialog } from 'electron';
import { app, dialog, shell } from 'electron';
import { printHelp } from './cli/help';
import { loadRawConfigStrict } from './config/load';
import {
@@ -18,7 +19,12 @@ import {
shouldHandleStatsDaemonCommandAtEntry,
} from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
import {
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
} from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
@@ -38,16 +44,105 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
}
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
const assets = resolvePackagedFirstRunPluginAssets({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
return (
resolvePackagedRuntimePluginPath({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
}) ?? undefined
);
}
function buildInstalledWindowsMpvPluginMessage(pathValue: string, version: string | null): string {
return [
'SubMiner detected an installed mpv plugin at:',
pathValue,
'',
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
].join('\n');
}
async function promptForWindowsLegacyMpvPluginRemoval(
mpvPath: string,
detection: { path: string | null; version: string | null },
): Promise<'removed' | 'continue' | 'cancel'> {
const response = await dialog.showMessageBox({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: buildInstalledWindowsMpvPluginMessage(
detection.path ?? 'unknown path',
detection.version,
),
detail:
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
defaultId: 0,
cancelId: 2,
});
if (!assets) {
return undefined;
if (response.response === 2) {
return 'cancel';
}
if (response.response === 1) {
return 'continue';
}
return path.join(assets.pluginDirSource, 'main.lua');
const candidates = detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
});
const result = await removeLegacyMpvPluginCandidates({
candidates,
trashItem: (candidatePath) => shell.trashItem(candidatePath),
});
if (result.ok) {
await dialog.showMessageBox({
type: 'info',
title: 'Legacy mpv plugin removed',
message:
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
});
return 'removed';
}
await dialog.showMessageBox({
type: 'error',
title: 'Could not remove legacy mpv plugin',
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
});
return 'cancel';
}
function createWindowsRuntimePluginPolicy() {
return {
detectInstalledMpvPlugin: (mpvPath: string) =>
detectInstalledMpvPlugin({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
}),
notifyInstalledPluginDetected: (detection: {
installed: boolean;
path: string | null;
version: string | null;
}) => {
if (!detection.installed || !detection.path) return;
dialog.showMessageBoxSync({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
});
},
resolveInstalledPluginBeforeLaunch: (
detection: { path: string | null; version: string | null },
mpvPath: string,
) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
};
}
function readConfiguredWindowsMpvLaunch(configDir: string): {
@@ -117,6 +212,7 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
resolveBundledWindowsMpvPluginEntrypoint(),
configuredMpvLaunch.executablePath,
configuredMpvLaunch.launchMode,
createWindowsRuntimePluginPolicy(),
);
app.exit(result.ok ? 0 : 1);
});
+126 -14
View File
@@ -382,7 +382,10 @@ import {
} from './main/runtime/first-run-setup-window';
import {
detectInstalledFirstRunPlugin,
installFirstRunPluginToDefaultLocation,
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
syncInstalledFirstRunPluginBinaryPath,
} from './main/runtime/first-run-setup-plugin';
import {
@@ -1063,6 +1066,89 @@ const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelection
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
clearScheduled: (timer) => clearTimeout(timer),
});
function resolveBundledMpvRuntimePluginEntrypoint(): string | undefined {
return (
resolvePackagedRuntimePluginPath({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
}) ?? undefined
);
}
function detectWindowsInstalledMpvPlugin(mpvExecutablePath: string) {
return detectInstalledMpvPlugin({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath,
});
}
function logInstalledMpvPluginDetected(detection: { path: string | null; version: string | null }) {
if (!detection.path) return;
logger.warn(
`SubMiner detected an installed mpv plugin at ${detection.path}. This mpv session will use the installed plugin. Remove it to use the bundled runtime plugin automatically. Detected plugin version: ${detection.version ?? 'unknown or legacy'}.`,
);
}
async function promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(
mpvPath: string,
detection: { path: string | null; version: string | null },
): Promise<'removed' | 'continue' | 'cancel'> {
const response = await dialog.showMessageBox({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: [
'SubMiner detected an installed mpv plugin at:',
detection.path ?? 'unknown path',
'',
"This mpv session will use the installed plugin unless it is removed. Remove it now to use SubMiner's bundled runtime plugin automatically.",
`Detected plugin version: ${detection.version ?? 'unknown or legacy'}`,
].join('\n'),
detail:
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash.',
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
defaultId: 0,
cancelId: 2,
});
if (response.response === 2) {
return 'cancel';
}
if (response.response === 1) {
return 'continue';
}
const result = await removeLegacyMpvPluginCandidates({
candidates: detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
}),
trashItem: (candidatePath) => shell.trashItem(candidatePath),
});
if (result.ok) {
await dialog.showMessageBox({
type: 'info',
title: 'Legacy mpv plugin removed',
message:
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
});
return 'removed';
}
await dialog.showMessageBox({
type: 'error',
title: 'Could not remove legacy mpv plugin',
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
});
return 'cancel';
}
const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
platform: process.platform,
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
@@ -1087,10 +1173,16 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
showError: (title, content) => dialog.showErrorBox(title, content),
}),
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
undefined,
undefined,
process.execPath,
resolveBundledMpvRuntimePluginEntrypoint(),
getResolvedConfig().mpv.executablePath,
getResolvedConfig().mpv.launchMode,
{
detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin,
notifyInstalledPluginDetected: logInstalledMpvPluginDetected,
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) =>
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
},
),
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
@@ -1127,6 +1219,16 @@ const firstRunSetupService = createFirstRunSetupService({
isExternalYomitanConfigured: () =>
getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
detectPluginInstalled: () => {
const candidates = detectInstalledFirstRunPluginCandidates({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
});
if (candidates.length > 0) {
return true;
}
const installPaths = resolveDefaultMpvInstallPaths(
process.platform,
os.homedir(),
@@ -1134,15 +1236,18 @@ const firstRunSetupService = createFirstRunSetupService({
);
return detectInstalledFirstRunPlugin(installPaths);
},
installPlugin: async () =>
installFirstRunPluginToDefaultLocation({
detectLegacyMpvPluginCandidates: () =>
detectInstalledFirstRunPluginCandidates({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
binaryPath: process.execPath,
appDataDir: app.getPath('appData'),
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
}),
removeLegacyMpvPlugins: (candidates) =>
removeLegacyMpvPluginCandidates({
candidates,
trashItem: (candidatePath) => shell.trashItem(candidatePath),
}),
detectWindowsMpvShortcuts: () => {
if (process.platform !== 'win32') {
@@ -1309,8 +1414,9 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
const immersionMediaRuntime = createImmersionMediaRuntime(
buildImmersionMediaRuntimeMainDepsHandler(),
);
const anilistRateLimiter = createAnilistRateLimiter();
const statsCoverArtFetcher = createCoverArtFetcher(
createAnilistRateLimiter(),
anilistRateLimiter,
createLogger('main:stats-cover-art'),
);
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
@@ -2639,6 +2745,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
legacyMpvPluginPaths: snapshot.legacyMpvPluginPaths,
mpvExecutablePath,
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
@@ -2648,8 +2755,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
handleAction: async (submission: FirstRunSetupSubmission) => {
if (submission.action === 'install-plugin') {
const snapshot = await firstRunSetupService.installMpvPlugin();
if (submission.action === 'remove-legacy-plugin') {
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
firstRunSetupMessage = snapshot.message;
return;
}
@@ -2998,7 +3105,9 @@ const {
},
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress(accessToken, title, episode),
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
}),
markSuccess: (key) => {
anilistUpdateQueue.markSuccess(key);
},
@@ -3044,7 +3153,9 @@ const {
},
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress(accessToken, title, episode),
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
}),
rememberAttemptedUpdateKey: (key) => {
rememberAnilistAttemptedUpdate(key);
},
@@ -3251,6 +3362,7 @@ const startLocalStatsServer = (): void => {
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
mpvSocketPath: appState.mpvSocketPath,
ankiConnectConfig: getResolvedConfig().ankiConnect,
anilistRateLimiter,
resolveAnkiNoteId: (noteId: number) =>
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
addYomitanNote: async (word: string) => {
+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) {
+11 -2
View File
@@ -178,10 +178,19 @@ test('release workflow skips empty AUR sync commits', () => {
assert.match(releaseWorkflow, /if git diff --quiet -- PKGBUILD \.SRCINFO; then/);
});
test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
test('Makefile does not expose the legacy global mpv plugin installer', () => {
assert.match(
makefile,
/windows\) printf '%s\\n' "\[INFO\] Windows builds run via: bun run build:win" ;;/,
);
assert.match(makefile, /bun \.\/scripts\/configure-plugin-binary-path\.mjs/);
assert.doesNotMatch(makefile, /^\s*install-plugin:/m);
assert.doesNotMatch(makefile, /\binstall-plugin\b/);
assert.doesNotMatch(makefile, /configure-plugin-binary-path\.mjs/);
});
test('Makefile uninstall targets remove bundled runtime plugin app-data copies', () => {
assert.match(makefile, /uninstall-linux:[\s\S]*@rm -rf "\$\(LINUX_DATA_DIR\)\/plugin\/subminer"/);
assert.match(makefile, /uninstall-macos:[\s\S]*@rm -rf "\$\(MACOS_DATA_DIR\)\/plugin\/subminer"/);
assert.match(makefile, /Removed:[\s\S]*\$\(LINUX_DATA_DIR\)\/plugin\/subminer/);
assert.match(makefile, /Removed:[\s\S]*\$\(MACOS_DATA_DIR\)\/plugin\/subminer/);
});