fix(linux): auto-install managed plugin copy; include in asset updates (#127)

This commit is contained in:
2026-06-14 17:25:28 -07:00
committed by GitHub
parent ae7e6f82a8
commit a117c5759c
53 changed files with 3050 additions and 152 deletions
+30 -2
View File
@@ -7,7 +7,7 @@ import { runConfigCommand } from './config-command.js';
import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js';
import { runLogsCommand } from './logs-command.js';
import { runMpvPreAppCommand } from './mpv-command.js';
import { runMpvPostAppCommand, runMpvPreAppCommand } from './mpv-command.js';
import { runAppPassthroughCommand } from './app-command.js';
import { runStatsCommand } from './stats-command.js';
import { runUpdateCommand } from './update-command.js';
@@ -262,7 +262,9 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
await assert.rejects(
async () => {
await runMpvPreAppCommand(context, {
ensureRuntimePluginReady: async () => {},
waitForUnixSocketReady: async () => false,
resolveRuntimePluginPath: () => null,
launchMpvIdleDetached: async () => {},
});
},
@@ -270,6 +272,32 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
);
});
test('mpv idle command ensures Linux runtime plugin before detached launch', async () => {
const context = createContext();
context.args.mpvIdle = true;
const calls: string[] = [];
const handled = await runMpvPostAppCommand(context, {
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
waitForUnixSocketReady: async () => {
calls.push('wait');
return true;
},
launchMpvIdleDetached: async () => {
calls.push('launch');
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
});
assert.equal(handled, true);
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait']);
});
test('dictionary command forwards --dictionary and target path to app binary', () => {
const context = createContext();
context.args.dictionary = true;
@@ -361,7 +389,7 @@ test('update command runs direct Linux release update without launching Electron
'direct:/tmp/subminer.app:/tmp/subminer:stable',
'info:AppImage update: not-found',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
'info:Support assets update: skipped',
]);
});
+17 -1
View File
@@ -5,9 +5,12 @@ import {
resolveLauncherRuntimePluginPath,
} from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
interface MpvCommandDeps {
ensureRuntimePluginReady(context: LauncherCommandContext): Promise<void>;
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
resolveRuntimePluginPath(context: LauncherCommandContext): string | null;
launchMpvIdleDetached(
socketPath: string,
appPath: string,
@@ -18,7 +21,19 @@ interface MpvCommandDeps {
}
const defaultDeps: MpvCommandDeps = {
ensureRuntimePluginReady: async (context) => {
await ensureLinuxRuntimePluginAvailable({
appPath: context.appPath ?? undefined,
scriptPath: context.scriptPath,
logLevel: context.args.logLevel,
});
},
waitForUnixSocketReady,
resolveRuntimePluginPath: (context) =>
resolveLauncherRuntimePluginPath({
appPath: context.appPath ?? '',
scriptPath: context.scriptPath,
}),
launchMpvIdleDetached,
};
@@ -58,11 +73,12 @@ export async function runMpvPostAppCommand(
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
await deps.ensureRuntimePluginReady(context);
await deps.launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
deps.resolveRuntimePluginPath(context),
{
...pluginRuntimeConfig,
backend: args.backend,
@@ -112,6 +112,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -161,6 +162,7 @@ test('youtube app-owned playback disables mpv plugin auto-start', async () => {
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -227,6 +229,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
try {
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -278,6 +281,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -351,6 +355,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -420,6 +425,7 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -441,3 +447,34 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
});
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
const calls: string[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
assert.deepEqual(calls, ['plugin', 'startMpv']);
});
+11
View File
@@ -19,6 +19,7 @@ import type { Args } from '../types.js';
import { nowMs } from '../time.js';
import type { LauncherCommandContext } from './context.js';
import { ensureLauncherSetupReady } from '../setup-gate.js';
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
import {
getDefaultConfigDir,
getSetupStatePath,
@@ -144,6 +145,13 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
return runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady,
ensureRuntimePluginReady: async (commandContext) => {
await ensureLinuxRuntimePluginAvailable({
appPath: commandContext.appPath ?? undefined,
scriptPath: commandContext.scriptPath,
logLevel: commandContext.args.logLevel,
});
},
chooseTarget,
checkDependencies,
registerCleanup,
@@ -160,6 +168,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
type PlaybackCommandDeps = {
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
ensureRuntimePluginReady: (context: LauncherCommandContext) => Promise<void>;
chooseTarget: (
args: Args,
scriptPath: string,
@@ -253,6 +262,8 @@ export async function runPlaybackCommandWithDeps(
);
}
await deps.ensureRuntimePluginReady(context);
await deps.startMpv(
selectedTarget.target,
selectedTarget.kind,
+7 -3
View File
@@ -34,7 +34,10 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
return {
appImage: { status: 'updated' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
supportAssets: [
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
],
};
},
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
@@ -48,7 +51,8 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
'info:AppImage update: updated',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
'info:Support assets (theme) update: updated - Installed theme.',
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
]);
});
@@ -101,7 +105,7 @@ test('runUpdateCommand skips Linux asset replacement when release is not newer',
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
'info:AppImage update: up to date',
'info:Launcher update: up to date',
'info:Rofi theme update: up to date',
'info:Support assets update: up to date',
]);
} finally {
globalThis.fetch = originalFetch;
+21 -7
View File
@@ -39,7 +39,12 @@ type DirectReleaseUpdateRequest = {
type DirectReleaseUpdateResult = {
appImage: { status: string; command?: string; message?: string };
launcher: { status: string; command?: string; message?: string };
supportAssets: Array<{ status: string; command?: string; message?: string }>;
supportAssets: Array<{
status: string;
component?: 'theme' | 'plugin';
command?: string;
message?: string;
}>;
};
type UpdateCommandDeps = {
@@ -124,20 +129,29 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
function logUpdateResult(
label: string,
result: { status: string; command?: string; message?: string },
result: {
status: string;
component?: 'theme' | 'plugin';
command?: string;
message?: string;
},
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
deps: Pick<UpdateCommandDeps, 'log'>,
): void {
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`);
const componentLabel = result.component ? ` (${result.component})` : '';
const detailSuffix = result.message ? ` - ${result.message}` : '';
deps.log(
'info',
configuredLogLevel,
`${label}${componentLabel} update: ${displayStatus}${detailSuffix}`,
);
if (result.command) {
deps.log(
'warn',
configuredLogLevel,
`${label} update requires manual command: ${result.command}`,
`${label}${componentLabel} update requires manual command: ${result.command}`,
);
} else if (result.message) {
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
}
}
@@ -187,7 +201,7 @@ export async function runUpdateCommand(
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
for (const supportResult of result.supportAssets) {
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
logUpdateResult('Support assets', supportResult, logLevel, resolvedDeps);
}
return true;
}
+134
View File
@@ -0,0 +1,134 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Args } from './types.js';
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
function createArgs(): Args {
return {
backend: 'auto',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubeMode: 'download',
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 0,
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: '',
youtubeSubgenKeepTemp: false,
youtubeFixWithAi: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: '',
aiConfig: {},
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
target: '',
targetKind: '',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'ja',
jimakuMaxEntryResults: 20,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
settings: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: 'https://jellyfin.example.test',
jellyfinUsername: '',
jellyfinPassword: '',
launchMode: 'normal',
};
}
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
const calls: string[] = [];
try {
await assert.rejects(
() =>
runJellyfinPlayMenuWithDeps(
'/tmp/SubMiner.AppImage',
createArgs(),
'/tmp/subminer',
'/tmp/subminer.sock',
{
loadLauncherJellyfinConfig: () => ({}),
findRofiTheme: () => null,
resolveJellyfinSelection: async () => 'item-123',
resolveJellyfinSelectionViaApp: async () => {
throw new Error('unexpected app-based selection');
},
hasStoredJellyfinSession: () => true,
requestJellyfinPreviewAuthFromApp: async () => null,
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
pathExists: () => false,
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
waitForUnixSocketReady: async () => {
calls.push('wait');
return true;
},
launchMpvIdleDetached: async () => {
calls.push('launch');
},
resolveLauncherRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
runAppCommandWithInheritLogged: () => {
calls.push('handoff');
throw new Error('stop after handoff');
},
log: () => {},
},
),
/stop after handoff/,
);
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
} finally {
if (originalAccessToken === undefined) {
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
} else {
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
}
if (originalUserId === undefined) {
delete process.env.SUBMINER_JELLYFIN_USER_ID;
} else {
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
}
}
});
+87 -19
View File
@@ -30,9 +30,54 @@ import {
resolveLauncherRuntimePluginPath,
waitForUnixSocketReady,
} from './mpv.js';
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
type JellyfinPlayMenuDeps = {
loadLauncherJellyfinConfig: typeof loadLauncherJellyfinConfig;
findRofiTheme: typeof findRofiTheme;
resolveJellyfinSelection: typeof resolveJellyfinSelection;
hasStoredJellyfinSession: typeof hasStoredJellyfinSession;
requestJellyfinPreviewAuthFromApp: typeof requestJellyfinPreviewAuthFromApp;
resolveLauncherMainConfigPath: typeof resolveLauncherMainConfigPath;
resolveJellyfinSelectionViaApp: typeof resolveJellyfinSelectionViaApp;
pathExists: (candidate: string) => boolean;
ensureRuntimePluginReady: (options: {
appPath: string;
scriptPath: string;
logLevel: Args['logLevel'];
}) => Promise<void>;
waitForUnixSocketReady: typeof waitForUnixSocketReady;
launchMpvIdleDetached: typeof launchMpvIdleDetached;
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
log: typeof log;
};
const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
loadLauncherJellyfinConfig,
findRofiTheme,
resolveJellyfinSelection,
hasStoredJellyfinSession,
requestJellyfinPreviewAuthFromApp,
resolveLauncherMainConfigPath,
resolveJellyfinSelectionViaApp,
pathExists: (candidate) => fs.existsSync(candidate),
ensureRuntimePluginReady: async ({ appPath, scriptPath, logLevel }) => {
await ensureLinuxRuntimePluginAvailable({
appPath,
scriptPath,
logLevel,
});
},
waitForUnixSocketReady,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
runAppCommandWithInheritLogged,
log,
};
export function sanitizeServerUrl(value: string): string {
return value.trim().replace(/\/+$/, '');
}
@@ -974,7 +1019,17 @@ export async function runJellyfinPlayMenu(
scriptPath: string,
mpvSocketPath: string,
): Promise<never> {
const config = loadLauncherJellyfinConfig();
return runJellyfinPlayMenuWithDeps(appPath, args, scriptPath, mpvSocketPath);
}
export async function runJellyfinPlayMenuWithDeps(
appPath: string,
args: Args,
scriptPath: string,
mpvSocketPath: string,
deps: JellyfinPlayMenuDeps = defaultJellyfinPlayMenuDeps,
): Promise<never> {
const config = deps.loadLauncherJellyfinConfig();
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
const session: JellyfinSessionConfig = {
@@ -986,58 +1041,71 @@ export async function runJellyfinPlayMenu(
iconCacheDir: config.iconCacheDir || '',
};
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
const rofiTheme = args.useRofi ? deps.findRofiTheme(scriptPath) : null;
if (args.useRofi && !rofiTheme) {
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
deps.log(
'warn',
args.logLevel,
'Rofi theme not found for Jellyfin picker; using rofi defaults.',
);
}
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
let itemId = '';
if (hasDirectSession) {
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
itemId = await deps.resolveJellyfinSelection(args, session, rofiTheme);
} else {
const configPath = resolveLauncherMainConfigPath();
if (!hasStoredJellyfinSession(configPath)) {
const configPath = deps.resolveLauncherMainConfigPath();
if (!deps.hasStoredJellyfinSession(configPath)) {
fail(
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
);
}
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args);
const previewAuth = await deps.requestJellyfinPreviewAuthFromApp(appPath, args);
if (previewAuth) {
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
session.accessToken = previewAuth.accessToken;
session.userId = previewAuth.userId || session.userId;
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.');
deps.log(
'debug',
args.logLevel,
'Jellyfin preview auth bridge ready for picker image previews.',
);
} else {
log(
deps.log(
'debug',
args.logLevel,
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
);
}
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
itemId = await deps.resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
}
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
deps.log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
deps.log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
let mpvReady = false;
if (fs.existsSync(mpvSocketPath)) {
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
if (deps.pathExists(mpvSocketPath)) {
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 250);
}
if (!mpvReady) {
await launchMpvIdleDetached(
await deps.ensureRuntimePluginReady({ appPath, scriptPath, logLevel: args.logLevel });
await deps.launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
deps.resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
);
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
}
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
deps.log(
'debug',
args.logLevel,
`MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`,
);
if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
}
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
}
+259
View File
@@ -0,0 +1,259 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {
ensureLinuxRuntimePluginAvailable,
installManagedPluginAssetsViaApp,
} from './runtime-plugin-preflight';
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'darwin',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: () => {
calls.push('log');
},
});
assert.deepEqual(calls, []);
});
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => {
calls.push('detect');
return true;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
isManagedThemeAvailable: () => {
calls.push('theme');
return true;
},
log: () => {},
});
assert.deepEqual(calls, ['detect', 'theme']);
});
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
isManagedThemeAvailable: () => {
calls.push('theme');
return true;
},
log: () => {},
});
assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
isManagedThemeAvailable: () => {
calls.push('theme');
return false;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.deepEqual(calls, [
'detect',
'resolve',
'theme',
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
'resolve',
]);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
const calls: string[] = [];
let resolveCount = 0;
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => {
resolveCount += 1;
calls.push(`resolve:${resolveCount}`);
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.deepEqual(calls, [
'resolve:1',
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
'resolve:2',
]);
});
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: false,
status: 'failed',
error: 'copy failed',
}),
log: () => {},
}),
/copy failed/,
);
});
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
log: () => {},
}),
/managed runtime plugin assets could not be installed/i,
);
});
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
let waited = false;
const result = await installManagedPluginAssetsViaApp(
{
appPath: '/opt/SubMiner/subminer',
},
{
runAppCommandCaptureOutput: () => ({
status: 1,
stdout: '',
stderr: '',
error: new Error('spawn failed'),
}),
waitForInstallResponse: async () => {
waited = true;
return null;
},
},
);
assert.deepEqual(result, {
ok: false,
status: 'failed',
error: 'spawn failed',
});
assert.equal(waited, false);
});
test('installManagedPluginAssetsViaApp does not let temp cleanup errors mask install result', async () => {
const originalRmSync = fs.rmSync;
fs.rmSync = ((targetPath, options) => {
if (String(targetPath).includes('subminer-runtime-plugin-')) {
throw new Error('cleanup failed');
}
return originalRmSync(targetPath, options);
}) as typeof fs.rmSync;
try {
const result = await installManagedPluginAssetsViaApp(
{
appPath: '/opt/SubMiner/subminer',
},
{
runAppCommandCaptureOutput: () => ({
status: 0,
stdout: '',
stderr: '',
}),
waitForInstallResponse: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
},
);
assert.deepEqual(result, {
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
});
} finally {
fs.rmSync = originalRmSync;
}
});
+223
View File
@@ -0,0 +1,223 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { log as launcherLog } from './log.js';
import { runAppCommandCaptureOutput, resolveLauncherRuntimePluginPath } from './mpv.js';
import { nowMs } from './time.js';
import { sleep } from './util.js';
import { detectInstalledMpvPlugin } from '../src/main/runtime/first-run-setup-plugin.js';
import {
resolveManagedLinuxRuntimePluginPaths,
type EnsureLinuxRuntimePluginAssetsResult,
} from '../src/main/runtime/linux-runtime-plugin-assets.js';
const RESPONSE_TIMEOUT_MS = 30_000;
type PreflightLog = (
level: 'debug' | 'info' | 'warn' | 'error',
configured: 'debug' | 'info' | 'warn' | 'error',
message: string,
) => void;
type EnsureLinuxRuntimePluginAvailableOptions = {
appPath?: string;
scriptPath?: string;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
platform?: NodeJS.Platform;
homeDir?: string;
xdgConfigHome?: string;
xdgDataHome?: string;
appDataDir?: string;
detectInstalledPlugin?: () => boolean;
resolveRuntimePluginPath?: () => string | null;
isManagedThemeAvailable?: () => boolean;
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
log?: PreflightLog;
};
type RuntimePluginPreflightResponse = {
ok: boolean;
status: 'installed' | 'already-present' | 'failed';
path?: string;
error?: string;
};
function resolveConfiguredLogLevel(
logLevel: EnsureLinuxRuntimePluginAvailableOptions['logLevel'],
): 'debug' | 'info' | 'warn' | 'error' {
return logLevel ?? 'warn';
}
async function waitForInstallResponse(
responsePath: string,
): Promise<RuntimePluginPreflightResponse | null> {
const deadline = nowMs() + RESPONSE_TIMEOUT_MS;
while (nowMs() < deadline) {
try {
if (fs.existsSync(responsePath)) {
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as RuntimePluginPreflightResponse;
}
} catch {
// retry until timeout
}
await sleep(100);
}
return null;
}
type InstallManagedPluginAssetsViaAppDeps = {
runAppCommandCaptureOutput?: typeof runAppCommandCaptureOutput;
waitForInstallResponse?: typeof waitForInstallResponse;
};
export async function installManagedPluginAssetsViaApp(
options: {
appPath: string;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
},
deps: InstallManagedPluginAssetsViaAppDeps = {},
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-runtime-plugin-'));
const responsePath = path.join(tempDir, 'response.json');
const runAppCommand = deps.runAppCommandCaptureOutput ?? runAppCommandCaptureOutput;
const waitForResponse = deps.waitForInstallResponse ?? waitForInstallResponse;
try {
const appArgs = [
'--ensure-linux-runtime-plugin-assets',
'--ensure-linux-runtime-plugin-assets-response-path',
responsePath,
];
const result = runAppCommand(options.appPath, appArgs);
if (result.error) {
return {
ok: false,
status: 'failed',
error: result.error.message,
};
}
if (result.status !== 0) {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
return {
ok: false,
status: 'failed',
error:
stderr ||
stdout ||
`Linux runtime plugin asset install command exited with status ${result.status}.`,
};
}
const response = await waitForResponse(responsePath);
if (response) {
return response;
}
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
return {
ok: false,
status: 'failed',
error:
stderr ||
stdout ||
`Timed out waiting for Linux runtime plugin asset response after app exit status ${result.status}.`,
};
} finally {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Avoid hiding the install failure or success result behind temp cleanup errors.
}
}
}
export async function ensureLinuxRuntimePluginAvailable(
options: EnsureLinuxRuntimePluginAvailableOptions,
): Promise<void> {
const platform = options.platform ?? process.platform;
if (platform !== 'linux') {
return;
}
const configuredLogLevel = resolveConfiguredLogLevel(options.logLevel);
const log = options.log ?? launcherLog;
const homeDir = options.homeDir ?? os.homedir();
const detectInstalledPlugin =
options.detectInstalledPlugin ??
(() =>
detectInstalledMpvPlugin({
platform,
homeDir,
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
appDataDir: options.appDataDir ?? process.env.APPDATA,
}).installed);
const installedPluginAvailable = detectInstalledPlugin();
const managedPaths = resolveManagedLinuxRuntimePluginPaths({
homeDir,
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
});
const resolveRuntimePluginPath =
options.resolveRuntimePluginPath ??
(() => {
if (!options.appPath) return null;
return resolveLauncherRuntimePluginPath({
appPath: options.appPath,
scriptPath: options.scriptPath,
platform,
homeDir,
env: process.env,
});
});
const isManagedThemeAvailable =
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
if (runtimePluginAvailable && isManagedThemeAvailable()) {
return;
}
log(
'info',
configuredLogLevel,
'Linux runtime support assets missing; installing managed plugin/theme assets.',
);
const installManagedPluginAssets =
options.installManagedPluginAssets ??
(() => {
if (!options.appPath) {
throw new Error(
'Linux managed runtime plugin assets could not be installed. Launch aborted before starting mpv.',
);
}
return installManagedPluginAssetsViaApp({
appPath: options.appPath,
logLevel: options.logLevel,
});
});
const installResult = await installManagedPluginAssets();
if (!installResult.ok) {
const message = installResult.error || 'Unknown Linux runtime plugin asset install failure.';
log(
'warn',
configuredLogLevel,
`Managed Linux runtime support asset install failed: ${message}`,
);
throw new Error(message);
}
log(
'info',
configuredLogLevel,
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
);
const runtimePluginPath = resolveRuntimePluginPath();
if (runtimePluginPath) {
return;
}
const message =
`Linux managed runtime plugin assets could not be installed. ` +
`Checked path: ${managedPaths.pluginEntrypointPath}. ` +
'Launch aborted before starting mpv.';
log('warn', configuredLogLevel, message);
throw new Error(message);
}
+37 -2
View File
@@ -24,6 +24,7 @@ type SmokeCase = {
artifactsDir: string;
binDir: string;
xdgConfigHome: string;
xdgDataHome: string;
appDataDir: string;
localAppDataDir: string;
homeDir: string;
@@ -64,6 +65,7 @@ function createSmokeCase(name: string): SmokeCase {
const artifactsDir = path.join(root, 'artifacts');
const binDir = path.join(root, 'bin');
const xdgConfigHome = path.join(root, 'xdg');
const xdgDataHome = path.join(root, 'xdg-data');
const appDataDir = path.join(root, 'AppData', 'Roaming');
const localAppDataDir = path.join(root, 'AppData', 'Local');
const homeDir = path.join(root, 'home');
@@ -135,6 +137,7 @@ process.on('SIGTERM', closeAndExit);
fakeAppBasePath,
`#!/usr/bin/env bun
const fs = require('node:fs');
const path = require('node:path');
const logPath = ${JSON.stringify(fakeAppLogPath)};
const startPath = ${JSON.stringify(fakeAppStartLogPath)};
@@ -154,6 +157,25 @@ if (entry.argv.includes('--stop')) {
if (entry.argv.includes('--app-ping')) {
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
}
if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) {
const responseFlagIndex = entry.argv.indexOf('--ensure-linux-runtime-plugin-assets-response-path');
const responsePath = responseFlagIndex >= 0 ? entry.argv[responseFlagIndex + 1] : '';
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(process.env.HOME || '', '.local', 'share');
const dataDir = path.join(xdgDataHome, 'SubMiner');
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
fs.mkdirSync(pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(themePath), { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
fs.writeFileSync(themePath, '/* smoke theme */\\n');
if (responsePath) {
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
}
process.exit(0);
}
process.exit(0);
`,
@@ -164,6 +186,7 @@ process.exit(0);
artifactsDir,
binDir,
xdgConfigHome,
xdgDataHome,
appDataDir,
localAppDataDir,
homeDir,
@@ -181,6 +204,7 @@ function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
...process.env,
HOME: smokeCase.homeDir,
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
XDG_DATA_HOME: smokeCase.xdgDataHome,
APPDATA: smokeCase.appDataDir,
LOCALAPPDATA: smokeCase.localAppDataDir,
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
@@ -495,7 +519,7 @@ test(
);
test(
'launcher start-overlay attaches to a running background app without spawning another app command',
'launcher start-overlay attaches to a running background app without spawning another app start command',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
@@ -530,7 +554,13 @@ test(
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(appEntries.length, 0);
assert.equal(appEntries.length > 0, true);
assert.equal(
appEntries.every((entry) =>
(entry.argv as string[]).includes('--ensure-linux-runtime-plugin-assets'),
),
true,
);
assert.equal(appStartEntries.length, 0);
assert.equal(appStopEntries.length, 0);
assert.equal(controlEntries.length, 1);
@@ -587,6 +617,11 @@ test(
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
);
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
assert.match(result.stdout, /managed plugin\/theme assets/i);
assert.equal(
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
true,
);
});
},
);