mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
@@ -567,9 +567,11 @@ export function buildSubminerScriptOpts(
|
||||
logLevel: LogLevel = 'info',
|
||||
extraParts: string[] = [],
|
||||
): string {
|
||||
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
|
||||
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
|
||||
const parts = [
|
||||
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
||||
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
||||
...(hasBinaryPath ? [] : [`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`]),
|
||||
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
|
||||
...extraParts.map(sanitizeScriptOptValue),
|
||||
];
|
||||
if (logLevel !== 'info') {
|
||||
|
||||
@@ -6,8 +6,8 @@ export function runAppPassthroughCommand(context: LauncherCommandContext): boole
|
||||
if (!appPath) {
|
||||
return false;
|
||||
}
|
||||
if (args.configSettings) {
|
||||
runAppCommandWithInherit(appPath, ['--config']);
|
||||
if (args.settings) {
|
||||
runAppCommandWithInherit(appPath, ['--settings']);
|
||||
return true;
|
||||
}
|
||||
if (!args.appPassthrough) {
|
||||
|
||||
@@ -38,9 +38,14 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
appPath: '/tmp/subminer.app',
|
||||
launcherJellyfinConfig: {},
|
||||
|
||||
@@ -13,6 +13,7 @@ interface MpvCommandDeps {
|
||||
appPath: string,
|
||||
args: LauncherCommandContext['args'],
|
||||
runtimePluginPath?: string | null,
|
||||
runtimePluginConfig?: LauncherCommandContext['pluginRuntimeConfig'],
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ export async function runMpvPostAppCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: MpvCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const { args, appPath, scriptPath, mpvSocketPath } = context;
|
||||
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig } = context;
|
||||
if (!args.mpvIdle) {
|
||||
return false;
|
||||
}
|
||||
@@ -62,6 +63,11 @@ export async function runMpvPostAppCommand(
|
||||
appPath,
|
||||
args,
|
||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
{
|
||||
...pluginRuntimeConfig,
|
||||
backend: args.backend,
|
||||
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
);
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
if (!ready) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||
import { state } from '../mpv.js';
|
||||
@@ -53,7 +56,7 @@ function createContext(): LauncherCommandContext {
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
configSettings: false,
|
||||
settings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
@@ -72,9 +75,14 @@ function createContext(): LauncherCommandContext {
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
appPath: '/tmp/SubMiner.AppImage',
|
||||
launcherJellyfinConfig: {},
|
||||
@@ -140,18 +148,24 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||
});
|
||||
|
||||
test('plugin auto-start playback marks background app for cleanup when mpv exits', async () => {
|
||||
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const appPath = context.appPath ?? '';
|
||||
state.appPath = appPath;
|
||||
@@ -164,7 +178,7 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
||||
mpvProc.exitCode = null;
|
||||
mpvProc.killed = false;
|
||||
mpvProc.kill = () => true;
|
||||
let cleanupSawManagedOverlay = false;
|
||||
let cleanupSawManagedOverlay = true;
|
||||
|
||||
try {
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
@@ -190,9 +204,178 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
||||
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
||||
});
|
||||
|
||||
assert.equal(cleanupSawManagedOverlay, true);
|
||||
assert.equal(cleanupSawManagedOverlay, false);
|
||||
} finally {
|
||||
state.appPath = '';
|
||||
state.overlayManagedByLauncher = false;
|
||||
}
|
||||
});
|
||||
|
||||
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
calls.push('startMpv');
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
isAppControlServerAvailable: async () => true,
|
||||
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||
isAppControlServerAvailable: () => Promise<boolean>;
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
|
||||
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(
|
||||
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
|
||||
?.autoStart,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
|
||||
const context = createContext();
|
||||
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
||||
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
let availabilityConfigDir: string | undefined;
|
||||
let overlayConfigDir: string | undefined;
|
||||
|
||||
try {
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
|
||||
overlayConfigDir = configDir;
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
isAppControlServerAvailable: async (_logLevel, configDir) => {
|
||||
availabilityConfigDir = configDir;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(availabilityConfigDir, expectedConfigDir);
|
||||
assert.equal(overlayConfigDir, expectedConfigDir);
|
||||
} finally {
|
||||
if (originalXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
||||
}
|
||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
isAppControlServerAvailable: async () => true,
|
||||
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||
isAppControlServerAvailable: () => Promise<boolean>;
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
||||
import {
|
||||
cleanupPlaybackSession,
|
||||
launchAppCommandDetached,
|
||||
markOverlayManagedByLauncher,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
isRunningAppControlServerAvailable,
|
||||
startMpv,
|
||||
startOverlay,
|
||||
state,
|
||||
@@ -30,6 +30,13 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
|
||||
function getLauncherConfigDir(): string {
|
||||
return getDefaultConfigDir({
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
});
|
||||
}
|
||||
|
||||
function checkDependencies(args: Args): void {
|
||||
const missing: string[] = [];
|
||||
|
||||
@@ -100,10 +107,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const { args, appPath } = context;
|
||||
if (!appPath) return;
|
||||
|
||||
const configDir = getDefaultConfigDir({
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
});
|
||||
const configDir = getLauncherConfigDir();
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
@@ -147,6 +151,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
waitForUnixSocketReady,
|
||||
startOverlay,
|
||||
launchAppCommandDetached,
|
||||
isAppControlServerAvailable: isRunningAppControlServerAvailable,
|
||||
log,
|
||||
cleanupPlaybackSession,
|
||||
getMpvProc: () => state.mpvProc,
|
||||
@@ -165,6 +170,7 @@ type PlaybackCommandDeps = {
|
||||
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||
startOverlay: typeof startOverlay;
|
||||
launchAppCommandDetached: typeof launchAppCommandDetached;
|
||||
isAppControlServerAvailable?: (logLevel: Args['logLevel'], configDir: string) => Promise<boolean>;
|
||||
log: typeof log;
|
||||
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
||||
getMpvProc: () => typeof state.mpvProc;
|
||||
@@ -209,11 +215,23 @@ export async function runPlaybackCommandWithDeps(
|
||||
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
||||
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
||||
const youtubeMode = args.youtubeMode ?? 'download';
|
||||
const configDir = getLauncherConfigDir();
|
||||
|
||||
if (isYoutubeUrl) {
|
||||
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
||||
}
|
||||
|
||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||
const shouldLauncherAttachRunningApp =
|
||||
pluginAutoStartEnabled &&
|
||||
!args.startOverlay &&
|
||||
!args.autoStartOverlay &&
|
||||
!isAppOwnedYoutubeFlow &&
|
||||
((await deps.isAppControlServerAvailable?.(args.logLevel, configDir)) ?? false);
|
||||
const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp
|
||||
? { ...pluginRuntimeConfig, autoStart: false }
|
||||
: pluginRuntimeConfig;
|
||||
|
||||
const shouldPauseUntilOverlayReady =
|
||||
pluginRuntimeConfig.autoStart &&
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
||||
@@ -238,12 +256,20 @@ export async function runPlaybackCommandWithDeps(
|
||||
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
runtimePluginConfig: {
|
||||
...effectivePluginRuntimeConfig,
|
||||
backend: args.backend,
|
||||
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
|
||||
const shouldStartOverlay =
|
||||
args.startOverlay ||
|
||||
args.autoStartOverlay ||
|
||||
isAppOwnedYoutubeFlow ||
|
||||
shouldLauncherAttachRunningApp;
|
||||
if (shouldStartOverlay) {
|
||||
if (ready) {
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
||||
@@ -254,16 +280,20 @@ export async function runPlaybackCommandWithDeps(
|
||||
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
||||
);
|
||||
}
|
||||
await deps.startOverlay(
|
||||
appPath,
|
||||
args,
|
||||
mpvSocketPath,
|
||||
isAppOwnedYoutubeFlow
|
||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||
: [],
|
||||
);
|
||||
const extraAppArgs = isAppOwnedYoutubeFlow
|
||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||
: shouldLauncherAttachRunningApp
|
||||
? [
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay
|
||||
? '--show-visible-overlay'
|
||||
: '--hide-visible-overlay',
|
||||
...(args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled
|
||||
? ['--texthooker']
|
||||
: []),
|
||||
]
|
||||
: [];
|
||||
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs, configDir);
|
||||
} else if (pluginAutoStartEnabled) {
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
if (ready) {
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||
} else {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||
import { parseLauncherMpvConfig } from './config/mpv-config.js';
|
||||
import { readExternalYomitanProfilePath } from './config.js';
|
||||
import {
|
||||
getPluginConfigCandidates,
|
||||
parsePluginRuntimeConfigContent,
|
||||
buildPluginRuntimeScriptOptParts,
|
||||
parsePluginRuntimeConfigFromMainConfig,
|
||||
} from './config/plugin-runtime-config.js';
|
||||
import { getDefaultSocketPath } from './types.js';
|
||||
|
||||
@@ -86,10 +86,34 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
|
||||
mpv: {
|
||||
launchMode: ' maximized ',
|
||||
executablePath: 'ignored-here',
|
||||
socketPath: '/tmp/custom.sock',
|
||||
backend: 'x11',
|
||||
autoStartSubMiner: false,
|
||||
pauseUntilOverlayReady: false,
|
||||
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.launchMode, 'maximized');
|
||||
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||
assert.equal(parsed.backend, 'x11');
|
||||
assert.equal(parsed.autoStartSubMiner, false);
|
||||
assert.equal(parsed.pauseUntilOverlayReady, false);
|
||||
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(parsed.aniskipEnabled, false);
|
||||
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||
});
|
||||
|
||||
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
|
||||
const parsed = parseLauncherMpvConfig({
|
||||
mpv: {
|
||||
subminerBinaryPath: ' ',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.subminerBinaryPath, undefined);
|
||||
});
|
||||
|
||||
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
||||
@@ -102,39 +126,102 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
||||
assert.equal(parsed.launchMode, undefined);
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
|
||||
const parsed = parsePluginRuntimeConfigContent(`
|
||||
# comment
|
||||
socket_path = /tmp/custom.sock # trailing comment
|
||||
auto_start = yes
|
||||
auto_start_visible_overlay = true
|
||||
auto_start_pause_until_ready = 1
|
||||
`);
|
||||
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||
test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => {
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig({
|
||||
auto_start_overlay: false,
|
||||
texthooker: {
|
||||
launchAtStartup: false,
|
||||
},
|
||||
mpv: {
|
||||
socketPath: '/tmp/config.sock',
|
||||
backend: 'sway',
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.socketPath, '/tmp/config.sock');
|
||||
assert.equal(parsed.backend, 'sway');
|
||||
assert.equal(parsed.autoStart, true);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, true);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigContent falls back to disabled startup gate options', () => {
|
||||
const parsed = parsePluginRuntimeConfigContent(`
|
||||
auto_start = maybe
|
||||
auto_start_visible_overlay = no
|
||||
auto_start_pause_until_ready = off
|
||||
`);
|
||||
assert.equal(parsed.autoStart, false);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(parsed.texthookerEnabled, false);
|
||||
assert.equal(parsed.aniskipEnabled, false);
|
||||
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||
});
|
||||
|
||||
test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
|
||||
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig(null);
|
||||
|
||||
assert.equal(parsed.autoStart, true);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
assert.equal(parsed.texthookerEnabled, false);
|
||||
assert.equal(parsed.aniskipEnabled, true);
|
||||
assert.equal(parsed.aniskipButtonKey, 'TAB');
|
||||
});
|
||||
|
||||
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
|
||||
assert.deepEqual(
|
||||
getPluginConfigCandidates({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
}),
|
||||
['C:\\Users\\tester\\AppData\\Roaming\\mpv\\script-opts\\subminer.conf'],
|
||||
buildPluginRuntimeScriptOptParts(
|
||||
{
|
||||
socketPath: '/tmp/config.sock',
|
||||
binaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
backend: 'x11',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
'/fallback/SubMiner.AppImage',
|
||||
),
|
||||
[
|
||||
'subminer-binary_path=/opt/SubMiner/SubMiner.AppImage',
|
||||
'subminer-socket_path=/tmp/config.sock',
|
||||
'subminer-backend=x11',
|
||||
'subminer-auto_start=yes',
|
||||
'subminer-auto_start_visible_overlay=no',
|
||||
'subminer-auto_start_pause_until_ready=yes',
|
||||
'subminer-texthooker_enabled=no',
|
||||
'subminer-aniskip_enabled=no',
|
||||
'subminer-aniskip_button_key=F8',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildPluginRuntimeScriptOptParts strips script-option delimiters from string values', () => {
|
||||
assert.deepEqual(
|
||||
buildPluginRuntimeScriptOptParts(
|
||||
{
|
||||
socketPath: '/tmp/config.sock,subminer-auto_start=no\nother=yes',
|
||||
binaryPath: '/opt/SubMiner,\nSubMiner.AppImage',
|
||||
backend: 'x11',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8,\nF9',
|
||||
},
|
||||
'/fallback/SubMiner.AppImage',
|
||||
),
|
||||
[
|
||||
'subminer-binary_path=/opt/SubMiner SubMiner.AppImage',
|
||||
'subminer-socket_path=/tmp/config.sock subminer-auto_start=no other=yes',
|
||||
'subminer-backend=x11',
|
||||
'subminer-auto_start=yes',
|
||||
'subminer-auto_start_visible_overlay=no',
|
||||
'subminer-auto_start_pause_until_ready=yes',
|
||||
'subminer-texthooker_enabled=no',
|
||||
'subminer-aniskip_enabled=no',
|
||||
'subminer-aniskip_button_key=F8 F9',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
action: 'show',
|
||||
logLevel: 'warn',
|
||||
},
|
||||
settingsInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
@@ -159,13 +160,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
assert.equal(parsed.logLevel, 'warn');
|
||||
});
|
||||
|
||||
test('applyInvocationsToArgs maps bare config invocation to settings window', () => {
|
||||
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: {
|
||||
action: undefined,
|
||||
configInvocation: null,
|
||||
settingsInvocation: {
|
||||
logLevel: undefined,
|
||||
},
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
@@ -190,16 +192,54 @@ test('applyInvocationsToArgs maps bare config invocation to settings window', ()
|
||||
texthookerOpenBrowser: false,
|
||||
});
|
||||
|
||||
assert.equal(parsed.configSettings, true);
|
||||
assert.equal(parsed.settings, true);
|
||||
assert.equal(parsed.configPath, false);
|
||||
});
|
||||
|
||||
test('applyInvocationsToArgs fails when config invocation has no action', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
const error = withProcessExitIntercept(() => {
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: {
|
||||
action: undefined,
|
||||
},
|
||||
settingsInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
});
|
||||
|
||||
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: null,
|
||||
settingsInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
|
||||
@@ -118,7 +118,7 @@ export function createDefaultArgs(
|
||||
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
|
||||
|
||||
const parsed: Args = {
|
||||
backend: 'auto',
|
||||
backend: mpvConfig.backend ?? 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: '',
|
||||
@@ -158,7 +158,7 @@ export function createDefaultArgs(
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
update: false,
|
||||
configSettings: false,
|
||||
settings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
@@ -222,7 +222,7 @@ export function applyRootOptionsToArgs(
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.config === true) parsed.configSettings = true;
|
||||
if (options.settings === true) parsed.settings = true;
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||
@@ -311,10 +311,19 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
|
||||
}
|
||||
const action = (invocations.configInvocation.action || '').toLowerCase();
|
||||
if (!action) parsed.configSettings = true;
|
||||
else if (action === 'path') parsed.configPath = true;
|
||||
if (action === 'path') parsed.configPath = true;
|
||||
else if (action === 'show') parsed.configShow = true;
|
||||
else fail(`Unknown config action: ${invocations.configInvocation.action}`);
|
||||
else
|
||||
fail(
|
||||
`Unknown config action: ${invocations.configInvocation.action || '(none)'}. Expected path or show.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (invocations.settingsInvocation) {
|
||||
if (invocations.settingsInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
|
||||
}
|
||||
parsed.settings = true;
|
||||
}
|
||||
|
||||
if (invocations.mpvInvocation) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface CommandActionInvocation {
|
||||
export interface CliInvocations {
|
||||
jellyfinInvocation: JellyfinInvocation | null;
|
||||
configInvocation: CommandActionInvocation | null;
|
||||
settingsInvocation: CommandActionInvocation | null;
|
||||
mpvInvocation: CommandActionInvocation | null;
|
||||
appInvocation: { appArgs: string[] } | null;
|
||||
dictionaryTriggered: boolean;
|
||||
@@ -58,7 +59,7 @@ function applyRootOptions(program: Command): void {
|
||||
.option('--start', 'Explicitly start overlay')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.option('-v, --version', 'Show SubMiner version')
|
||||
.option('--config', 'Open configuration window')
|
||||
.option('--settings', 'Open settings window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||
@@ -88,6 +89,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
'jf',
|
||||
'doctor',
|
||||
'config',
|
||||
'settings',
|
||||
'mpv',
|
||||
'dictionary',
|
||||
'dict',
|
||||
@@ -138,6 +140,7 @@ export function parseCliPrograms(
|
||||
} {
|
||||
let jellyfinInvocation: JellyfinInvocation | null = null;
|
||||
let configInvocation: CommandActionInvocation | null = null;
|
||||
let settingsInvocation: CommandActionInvocation | null = null;
|
||||
let mpvInvocation: CommandActionInvocation | null = null;
|
||||
let appInvocation: { appArgs: string[] } | null = null;
|
||||
let dictionaryTriggered = false;
|
||||
@@ -293,7 +296,7 @@ export function parseCliPrograms(
|
||||
|
||||
commandProgram
|
||||
.command('config')
|
||||
.description('Config helpers')
|
||||
.description('Config file helpers (path|show)')
|
||||
.argument('[action]', 'path|show')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
@@ -303,6 +306,16 @@ export function parseCliPrograms(
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('settings')
|
||||
.description('Open SubMiner settings window')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((options: Record<string, unknown>) => {
|
||||
settingsInvocation = {
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('mpv')
|
||||
.description('MPV helpers')
|
||||
@@ -356,6 +369,7 @@ export function parseCliPrograms(
|
||||
invocations: {
|
||||
jellyfinInvocation,
|
||||
configInvocation,
|
||||
settingsInvocation,
|
||||
mpvInvocation,
|
||||
appInvocation,
|
||||
dictionaryTriggered,
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js';
|
||||
import type { Backend } from '../types.js';
|
||||
import type { LauncherMpvConfig } from '../types.js';
|
||||
|
||||
function parseBackend(value: unknown): Backend | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized === 'auto' ||
|
||||
normalized === 'hyprland' ||
|
||||
normalized === 'sway' ||
|
||||
normalized === 'x11' ||
|
||||
normalized === 'macos' ||
|
||||
normalized === 'windows'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherMpvConfig {
|
||||
const mpvRaw = root.mpv;
|
||||
if (!mpvRaw || typeof mpvRaw !== 'object') return {};
|
||||
@@ -8,5 +31,14 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
|
||||
|
||||
return {
|
||||
launchMode: parseMpvLaunchMode(mpv.launchMode),
|
||||
socketPath: parseNonEmptyString(mpv.socketPath),
|
||||
backend: parseBackend(mpv.backend),
|
||||
autoStartSubMiner:
|
||||
typeof mpv.autoStartSubMiner === 'boolean' ? mpv.autoStartSubMiner : undefined,
|
||||
pauseUntilOverlayReady:
|
||||
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
|
||||
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
|
||||
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
|
||||
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,126 +1,76 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { log } from '../log.js';
|
||||
import type { LogLevel, PluginRuntimeConfig } from '../types.js';
|
||||
import type { Backend, LogLevel, PluginRuntimeConfig } from '../types.js';
|
||||
import { DEFAULT_SOCKET_PATH } from '../types.js';
|
||||
import { buildSubminerPluginRuntimeScriptOptParts } from '../../src/shared/subminer-plugin-script-opts.js';
|
||||
import { parseLauncherMpvConfig } from './mpv-config.js';
|
||||
import { readLauncherMainConfigObject } from './shared-config-reader.js';
|
||||
|
||||
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
|
||||
return platform === 'win32' ? path.win32 : path.posix;
|
||||
function rootObject(root: Record<string, unknown> | null, key: string): Record<string, unknown> {
|
||||
const value = root?.[key];
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function getPluginConfigCandidates(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
xdgConfigHome?: string;
|
||||
appDataDir?: string;
|
||||
}): string[] {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const homeDir = options?.homeDir ?? os.homedir();
|
||||
const platformPath = getPlatformPath(platform);
|
||||
function booleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const appDataDir =
|
||||
options?.appDataDir?.trim() ||
|
||||
process.env.APPDATA?.trim() ||
|
||||
platformPath.join(homeDir, 'AppData', 'Roaming');
|
||||
return [platformPath.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')];
|
||||
function nonEmptyStringOrDefault(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : fallback;
|
||||
}
|
||||
|
||||
function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized === 'auto' ||
|
||||
normalized === 'hyprland' ||
|
||||
normalized === 'sway' ||
|
||||
normalized === 'x11' ||
|
||||
normalized === 'macos' ||
|
||||
normalized === 'windows'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const xdgConfigHome =
|
||||
options?.xdgConfigHome?.trim() ||
|
||||
process.env.XDG_CONFIG_HOME ||
|
||||
platformPath.join(homeDir, '.config');
|
||||
return Array.from(
|
||||
new Set([
|
||||
platformPath.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
platformPath.join(homeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
||||
]),
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function parsePluginRuntimeConfigContent(
|
||||
content: string,
|
||||
logLevel: LogLevel = 'warn',
|
||||
export function parsePluginRuntimeConfigFromMainConfig(
|
||||
root: Record<string, unknown> | null,
|
||||
): PluginRuntimeConfig {
|
||||
const runtimeConfig: PluginRuntimeConfig = {
|
||||
socketPath: DEFAULT_SOCKET_PATH,
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
const mpvConfig = root ? parseLauncherMpvConfig(root) : {};
|
||||
const texthooker = rootObject(root, 'texthooker');
|
||||
|
||||
return {
|
||||
socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH,
|
||||
binaryPath: mpvConfig.subminerBinaryPath ?? '',
|
||||
backend: validBackendOrDefault(mpvConfig.backend, 'auto'),
|
||||
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
|
||||
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
||||
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
||||
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
|
||||
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
|
||||
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
|
||||
};
|
||||
}
|
||||
|
||||
const parseBooleanValue = (key: string, value: string): boolean => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
|
||||
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
|
||||
log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`);
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
||||
const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
|
||||
if (!keyValueMatch) continue;
|
||||
const key = (keyValueMatch[1] || '').toLowerCase();
|
||||
const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || '';
|
||||
if (!value) continue;
|
||||
|
||||
if (key === 'socket_path') {
|
||||
runtimeConfig.socketPath = value;
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start') {
|
||||
runtimeConfig.autoStart = parseBooleanValue('auto_start', value);
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start_visible_overlay') {
|
||||
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
|
||||
'auto_start_visible_overlay',
|
||||
value,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start_pause_until_ready') {
|
||||
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
|
||||
'auto_start_pause_until_ready',
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
return runtimeConfig;
|
||||
export function buildPluginRuntimeScriptOptParts(
|
||||
runtimeConfig: PluginRuntimeConfig,
|
||||
fallbackAppPath: string,
|
||||
): string[] {
|
||||
return buildSubminerPluginRuntimeScriptOptParts(runtimeConfig, fallbackAppPath);
|
||||
}
|
||||
|
||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||
const candidates = getPluginConfigCandidates();
|
||||
const defaults: PluginRuntimeConfig = {
|
||||
socketPath: DEFAULT_SOCKET_PATH,
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
};
|
||||
|
||||
for (const configPath of candidates) {
|
||||
if (!fs.existsSync(configPath)) continue;
|
||||
try {
|
||||
const parsed = parsePluginRuntimeConfigContent(fs.readFileSync(configPath, 'utf8'));
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}`,
|
||||
);
|
||||
return parsed;
|
||||
} catch {
|
||||
log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject());
|
||||
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath}, auto_start=${defaults.autoStart}, auto_start_visible_overlay=${defaults.autoStartVisibleOverlay}, auto_start_pause_until_ready=${defaults.autoStartPauseUntilReady})`,
|
||||
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
|
||||
);
|
||||
return defaults;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
+67
-21
@@ -157,10 +157,10 @@ test('mpv socket command returns socket path from plugin runtime config', () =>
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const expectedSocket = path.join(root, 'custom', 'subminer.sock');
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${expectedSocket}\n`,
|
||||
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
JSON.stringify({ mpv: { socketPath: expectedSocket } }),
|
||||
);
|
||||
|
||||
const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome));
|
||||
@@ -175,10 +175,10 @@ test('mpv status exits non-zero when socket is not ready', () => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const socketPath = path.join(root, 'missing.sock');
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\n`,
|
||||
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
JSON.stringify({ mpv: { socketPath } }),
|
||||
);
|
||||
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
|
||||
|
||||
@@ -232,7 +232,7 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher config option forwards app configuration window command', () => {
|
||||
test('launcher settings option forwards app settings window command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
@@ -249,14 +249,14 @@ test('launcher config option forwards app configuration window command', () => {
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['--config'], env);
|
||||
const result = runLauncher(['--settings'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher config command forwards app configuration window command', () => {
|
||||
test('launcher settings command forwards app settings window command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
@@ -273,10 +273,38 @@ test('launcher config command forwards app configuration window command', () =>
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['config'], env);
|
||||
const result = runLauncher(['settings'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher settings command suppresses known Electron macOS menu diagnostics', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'printf "%s\\n" "2026-05-17 02:59:52.141 SubMiner[29060:305323] representedObject is not a WeakPtrToElectronMenuModelAsNSObject" >&2',
|
||||
'printf "%s\\n" "real stderr line" >&2',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
};
|
||||
const result = runLauncher(['settings'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.stderr, 'real stderr line\n');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,7 +321,6 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(videoPath, 'fake video content');
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||
@@ -308,8 +335,15 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`,
|
||||
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
JSON.stringify({
|
||||
auto_start_overlay: false,
|
||||
mpv: {
|
||||
socketPath,
|
||||
autoStartSubMiner: false,
|
||||
pauseUntilOverlayReady: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
@@ -373,7 +407,6 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(videoPath, 'fake video content');
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||
@@ -388,8 +421,15 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
||||
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
JSON.stringify({
|
||||
auto_start_overlay: true,
|
||||
mpv: {
|
||||
socketPath,
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
@@ -443,7 +483,6 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||
JSON.stringify({
|
||||
@@ -457,8 +496,15 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
||||
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
JSON.stringify({
|
||||
auto_start_overlay: true,
|
||||
mpv: {
|
||||
socketPath,
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
|
||||
+387
-1
@@ -6,6 +6,7 @@ import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Args } from './types';
|
||||
import { getAppControlSocketPath } from '../src/shared/app-control';
|
||||
import {
|
||||
buildConfiguredMpvDefaultArgs,
|
||||
buildMpvBackendArgs,
|
||||
@@ -114,6 +115,36 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
|
||||
}
|
||||
});
|
||||
|
||||
test('runAppCommandCaptureOutput transports Linux AppImage args through environment', () => {
|
||||
if (process.platform !== 'linux') return;
|
||||
const { dir } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'SubMiner.AppImage');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'printf "args:%s\\n" "$*"',
|
||||
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
|
||||
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
|
||||
'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
try {
|
||||
const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /^args:\n/m);
|
||||
assert.match(result.stdout, /^argc:2\n/m);
|
||||
assert.match(result.stdout, /^arg0:--app-ping\n/m);
|
||||
assert.match(result.stdout, /^arg1:--socket\n/m);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('parseMpvArgString preserves empty quoted tokens', () => {
|
||||
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
|
||||
'--title',
|
||||
@@ -264,6 +295,15 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
|
||||
});
|
||||
});
|
||||
|
||||
test('buildConfiguredMpvDefaultArgs disables macOS menu shortcuts so SubMiner bindings reach mpv', () => {
|
||||
withPlatform('darwin', () => {
|
||||
assert.equal(
|
||||
buildConfiguredMpvDefaultArgs(makeArgs()).includes('--macos-menu-shortcuts=no'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveLauncherRuntimePluginPath finds bundled plugin from explicit environment path', () => {
|
||||
const pluginDir = '/opt/SubMiner/plugin/subminer';
|
||||
assert.equal(
|
||||
@@ -530,7 +570,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
configSettings: false,
|
||||
settings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
@@ -616,6 +656,352 @@ test('startOverlay captures app stdout and stderr into app log', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('startOverlay starts launcher-owned playback in background managed mode', async () => {
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||
'if [ "$1" = "--app-ping" ]; then exit 1; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
fs.writeFileSync(socketPath, '');
|
||||
const originalCreateConnection = net.createConnection;
|
||||
try {
|
||||
net.createConnection = (() => {
|
||||
const socket = new EventEmitter() as net.Socket;
|
||||
socket.destroy = (() => socket) as net.Socket['destroy'];
|
||||
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
|
||||
setTimeout(() => socket.emit('connect'), 10);
|
||||
return socket;
|
||||
}) as typeof net.createConnection;
|
||||
|
||||
await startOverlay(appPath, makeArgs(), socketPath);
|
||||
|
||||
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
|
||||
assert.match(invocationText, /--background/);
|
||||
assert.match(invocationText, /--managed-playback/);
|
||||
assert.equal(state.overlayManagedByLauncher, true);
|
||||
assert.equal(state.appPath, appPath);
|
||||
} finally {
|
||||
net.createConnection = originalCreateConnection;
|
||||
state.overlayProc = null;
|
||||
state.overlayManagedByLauncher = false;
|
||||
state.appPath = '';
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
fs.writeFileSync(socketPath, '');
|
||||
const originalCreateConnection = net.createConnection;
|
||||
try {
|
||||
net.createConnection = (() => {
|
||||
const socket = new EventEmitter() as net.Socket;
|
||||
socket.destroy = (() => socket) as net.Socket['destroy'];
|
||||
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
|
||||
setTimeout(() => socket.emit('connect'), 10);
|
||||
return socket;
|
||||
}) as typeof net.createConnection;
|
||||
|
||||
await startOverlay(appPath, makeArgs(), socketPath);
|
||||
|
||||
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
|
||||
assert.match(invocationText, /--app-ping/);
|
||||
assert.match(invocationText, /--start/);
|
||||
assert.doesNotMatch(invocationText, /--background/);
|
||||
assert.equal(state.overlayManagedByLauncher, false);
|
||||
assert.equal(state.appPath, '');
|
||||
} finally {
|
||||
net.createConnection = originalCreateConnection;
|
||||
state.overlayProc = null;
|
||||
state.overlayManagedByLauncher = false;
|
||||
state.appPath = '';
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('startOverlay attaches through the running app control socket without spawning another app command', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const controlSocketPath = path.join(dir, 'control.sock');
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||
const receivedControlArgv: string[][] = [];
|
||||
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const mpvServer = net.createServer((socket) => socket.end());
|
||||
const controlServer = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
const newlineMatch = buffer.match(/\r?\n/);
|
||||
if (!newlineMatch || newlineMatch.index === undefined) return;
|
||||
const line = buffer.slice(0, newlineMatch.index).trim();
|
||||
buffer = buffer.slice(newlineMatch.index + newlineMatch[0].length);
|
||||
if (!line) return;
|
||||
const payload = JSON.parse(line) as { argv?: unknown };
|
||||
if (Array.isArray(payload.argv)) {
|
||||
receivedControlArgv.push(
|
||||
payload.argv.filter((value): value is string => typeof value === 'string'),
|
||||
);
|
||||
}
|
||||
socket.end(JSON.stringify({ ok: true }) + '\n');
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
mpvServer.once('error', reject);
|
||||
mpvServer.listen(socketPath, resolve);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
controlServer.once('error', reject);
|
||||
controlServer.listen(controlSocketPath, resolve);
|
||||
});
|
||||
|
||||
await startOverlay(appPath, makeArgs(), socketPath);
|
||||
|
||||
const invocationText = fs.existsSync(appInvocationsPath)
|
||||
? fs.readFileSync(appInvocationsPath, 'utf8')
|
||||
: '';
|
||||
assert.equal(invocationText, '');
|
||||
assert.equal(receivedControlArgv.length, 1);
|
||||
assert.deepEqual(receivedControlArgv[0]?.slice(0, 7), [
|
||||
'--start',
|
||||
'--managed-playback',
|
||||
'--backend',
|
||||
'x11',
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--log-level',
|
||||
]);
|
||||
assert.equal(state.overlayManagedByLauncher, false);
|
||||
assert.equal(state.appPath, '');
|
||||
} finally {
|
||||
if (originalControlSocket === undefined) {
|
||||
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||
} else {
|
||||
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
|
||||
}
|
||||
await new Promise<void>((resolve) => mpvServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
|
||||
state.overlayProc = null;
|
||||
state.overlayManagedByLauncher = false;
|
||||
state.appPath = '';
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('startOverlay uses caller config dir for app control socket discovery', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const configDir = path.join(dir, 'launcher-config');
|
||||
const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||
const receivedControlArgv: string[][] = [];
|
||||
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const mpvServer = net.createServer((socket) => socket.end());
|
||||
const controlServer = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
const newlineIndex = buffer.indexOf('\n');
|
||||
if (newlineIndex < 0) return;
|
||||
const payload = JSON.parse(buffer.slice(0, newlineIndex)) as { argv?: unknown };
|
||||
if (Array.isArray(payload.argv)) {
|
||||
receivedControlArgv.push(
|
||||
payload.argv.filter((value): value is string => typeof value === 'string'),
|
||||
);
|
||||
}
|
||||
socket.end(JSON.stringify({ ok: true }) + '\n');
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
mpvServer.once('error', reject);
|
||||
mpvServer.listen(socketPath, resolve);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
controlServer.once('error', reject);
|
||||
controlServer.listen(controlSocketPath, resolve);
|
||||
});
|
||||
|
||||
await startOverlay(appPath, makeArgs(), socketPath, [], configDir);
|
||||
|
||||
const invocationText = fs.existsSync(appInvocationsPath)
|
||||
? fs.readFileSync(appInvocationsPath, 'utf8')
|
||||
: '';
|
||||
assert.equal(invocationText, '');
|
||||
assert.equal(receivedControlArgv.length, 1);
|
||||
assert.deepEqual(receivedControlArgv[0]?.slice(0, 6), [
|
||||
'--start',
|
||||
'--managed-playback',
|
||||
'--backend',
|
||||
'x11',
|
||||
'--socket',
|
||||
socketPath,
|
||||
]);
|
||||
} finally {
|
||||
if (originalControlSocket === undefined) {
|
||||
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||
} else {
|
||||
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
|
||||
}
|
||||
await new Promise<void>((resolve) => mpvServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
|
||||
state.overlayProc = null;
|
||||
state.overlayManagedByLauncher = false;
|
||||
state.appPath = '';
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('startOverlay falls back to legacy app startup when control command fails', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const controlSocketPath = path.join(dir, 'control.sock');
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const controlServer = net.createServer((socket) => {
|
||||
socket.on('data', () => {
|
||||
socket.end(JSON.stringify({ ok: false, error: 'boom' }) + '\n');
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
controlServer.once('error', reject);
|
||||
controlServer.listen(controlSocketPath, resolve);
|
||||
});
|
||||
|
||||
await startOverlay(appPath, makeArgs(), socketPath);
|
||||
|
||||
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
|
||||
assert.match(invocationText, /--app-ping/);
|
||||
assert.match(invocationText, /--start/);
|
||||
} finally {
|
||||
if (originalControlSocket === undefined) {
|
||||
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||
} else {
|
||||
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
|
||||
}
|
||||
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
|
||||
state.overlayProc = null;
|
||||
state.overlayManagedByLauncher = false;
|
||||
state.appPath = '';
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
fs.writeFileSync(socketPath, '');
|
||||
const originalCreateConnection = net.createConnection;
|
||||
try {
|
||||
state.appPath = appPath;
|
||||
state.overlayManagedByLauncher = true;
|
||||
net.createConnection = (() => {
|
||||
const socket = new EventEmitter() as net.Socket;
|
||||
socket.destroy = (() => socket) as net.Socket['destroy'];
|
||||
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
|
||||
setTimeout(() => socket.emit('connect'), 10);
|
||||
return socket;
|
||||
}) as typeof net.createConnection;
|
||||
|
||||
await startOverlay(appPath, makeArgs(), socketPath);
|
||||
|
||||
assert.equal(state.overlayManagedByLauncher, true);
|
||||
assert.equal(state.appPath, appPath);
|
||||
} finally {
|
||||
net.createConnection = originalCreateConnection;
|
||||
state.overlayProc = null;
|
||||
state.overlayManagedByLauncher = false;
|
||||
state.appPath = '';
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => {
|
||||
const { dir } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
|
||||
+193
-25
@@ -4,14 +4,20 @@ import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
|
||||
import {
|
||||
isAppControlServerAvailable as checkAppControlServerAvailable,
|
||||
sendAppControlCommand,
|
||||
} from '../src/shared/app-control-client.js';
|
||||
import { getDefaultConfigDir } from '../src/shared/setup-state.js';
|
||||
import {
|
||||
detectInstalledMpvPlugin,
|
||||
type InstalledMpvPluginDetection,
|
||||
} from '../src/main/runtime/first-run-setup-plugin.js';
|
||||
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
||||
import type { LogLevel, Backend, Args, MpvTrack, PluginRuntimeConfig } from './types.js';
|
||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
|
||||
import { nowMs } from './time.js';
|
||||
import {
|
||||
commandExists,
|
||||
@@ -38,6 +44,7 @@ export const state = {
|
||||
type SpawnTarget = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
|
||||
@@ -45,6 +52,8 @@ type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | '
|
||||
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
||||
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
||||
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
||||
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
|
||||
export interface LauncherRuntimePluginPlan {
|
||||
scriptPath: string | null;
|
||||
@@ -849,6 +858,7 @@ export async function startMpv(
|
||||
startPaused?: boolean;
|
||||
disableYoutubeSubtitleAutoLoad?: boolean;
|
||||
runtimePluginPath?: string | null;
|
||||
runtimePluginConfig?: PluginRuntimeConfig;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||
@@ -916,13 +926,13 @@ export async function startMpv(
|
||||
options?.disableYoutubeSubtitleAutoLoad === true
|
||||
? ['subminer-auto_start_pause_until_ready=no']
|
||||
: [];
|
||||
const scriptOpts = buildSubminerScriptOpts(
|
||||
appPath,
|
||||
socketPath,
|
||||
aniSkipMetadata,
|
||||
args.logLevel,
|
||||
extraScriptOpts,
|
||||
);
|
||||
const runtimeScriptOpts = options?.runtimePluginConfig
|
||||
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
|
||||
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [
|
||||
...runtimeScriptOpts,
|
||||
...extraScriptOpts,
|
||||
]);
|
||||
if (aniSkipMetadata) {
|
||||
log(
|
||||
'debug',
|
||||
@@ -996,21 +1006,82 @@ export async function startOverlay(
|
||||
args: Args,
|
||||
socketPath: string,
|
||||
extraAppArgs: string[] = [],
|
||||
configDir: string = getLauncherConfigDir(),
|
||||
): Promise<void> {
|
||||
const backend = detectBackend(args.backend);
|
||||
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
||||
const alreadyManagedByLauncher = state.overlayManagedByLauncher && state.appPath === appPath;
|
||||
|
||||
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
|
||||
const overlayArgs = [
|
||||
'--start',
|
||||
'--managed-playback',
|
||||
'--backend',
|
||||
backend,
|
||||
'--socket',
|
||||
socketPath,
|
||||
...extraAppArgs,
|
||||
];
|
||||
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
||||
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
||||
|
||||
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
||||
const controlResult = await sendAppControlCommand(overlayArgs, {
|
||||
configDir,
|
||||
});
|
||||
if (controlResult.ok) {
|
||||
log('debug', args.logLevel, 'Attached to running SubMiner app via control socket');
|
||||
if (alreadyManagedByLauncher) {
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
} else {
|
||||
clearOverlayManagedByLauncher();
|
||||
state.overlayProc = null;
|
||||
}
|
||||
|
||||
const socketReady = await waitForUnixSocketReady(
|
||||
socketPath,
|
||||
OVERLAY_START_SOCKET_READY_TIMEOUT_MS,
|
||||
);
|
||||
if (!socketReady) {
|
||||
log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
'Overlay start continuing before mpv socket readiness was confirmed',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (controlResult.unavailable !== true) {
|
||||
log(
|
||||
'warn',
|
||||
args.logLevel,
|
||||
`Running SubMiner app control command failed: ${controlResult.error ?? 'unknown error'}`,
|
||||
);
|
||||
if (!alreadyManagedByLauncher) {
|
||||
clearOverlayManagedByLauncher();
|
||||
state.overlayProc = null;
|
||||
}
|
||||
}
|
||||
|
||||
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
|
||||
const borrowingExistingApp = appAlreadyRunning && !alreadyManagedByLauncher;
|
||||
const spawnOverlayArgs = [...overlayArgs];
|
||||
if (!borrowingExistingApp) spawnOverlayArgs.unshift('--background');
|
||||
|
||||
const target = resolveAppSpawnTarget(appPath, spawnOverlayArgs);
|
||||
state.overlayProc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(state.overlayProc);
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
if (borrowingExistingApp) {
|
||||
log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
'SubMiner app is already running; launcher will not stop it after playback',
|
||||
);
|
||||
clearOverlayManagedByLauncher();
|
||||
} else {
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
}
|
||||
|
||||
const [socketReady] = await Promise.all([
|
||||
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
|
||||
@@ -1030,6 +1101,26 @@ export async function startOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
function getLauncherConfigDir(): string {
|
||||
return getDefaultConfigDir({
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function isRunningAppControlServerAvailable(
|
||||
logLevel: LogLevel,
|
||||
configDir: string = getLauncherConfigDir(),
|
||||
): Promise<boolean> {
|
||||
const available = await checkAppControlServerAvailable({
|
||||
configDir,
|
||||
});
|
||||
if (available) {
|
||||
log('debug', logLevel, 'Running SubMiner app control socket detected');
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
export function markOverlayManagedByLauncher(appPath?: string): void {
|
||||
if (appPath) {
|
||||
state.appPath = appPath;
|
||||
@@ -1037,6 +1128,20 @@ export function markOverlayManagedByLauncher(appPath?: string): void {
|
||||
state.overlayManagedByLauncher = true;
|
||||
}
|
||||
|
||||
function clearOverlayManagedByLauncher(): void {
|
||||
state.appPath = '';
|
||||
state.overlayManagedByLauncher = false;
|
||||
}
|
||||
|
||||
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
|
||||
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
|
||||
if (result.error) {
|
||||
log('debug', logLevel, `App ping failed before overlay start: ${result.error.message}`);
|
||||
return false;
|
||||
}
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
|
||||
const target =
|
||||
process.platform === 'darwin'
|
||||
@@ -1144,7 +1249,7 @@ function stopManagedOverlayApp(args: Args): void {
|
||||
const target = resolveAppSpawnTarget(state.appPath, stopArgs);
|
||||
const result = spawnSync(target.command, target.args, {
|
||||
stdio: 'ignore',
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
if (result.error) {
|
||||
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
|
||||
@@ -1161,13 +1266,40 @@ function stopManagedOverlayApp(args: Args): void {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAppEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
function clearTransportedAppArgs(env: Record<string, string | undefined>): void {
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key === TRANSPORTED_APP_ARGC_ENV || /^SUBMINER_APP_ARG_\d+$/.test(key)) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildTransportedAppArgsEnv(appArgs: string[]): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
[TRANSPORTED_APP_ARGC_ENV]: String(appArgs.length),
|
||||
};
|
||||
appArgs.forEach((arg, index) => {
|
||||
env[`${TRANSPORTED_APP_ARG_PREFIX}${index}`] = arg;
|
||||
});
|
||||
return env;
|
||||
}
|
||||
|
||||
function shouldTransportAppArgsForAppImage(appPath: string): boolean {
|
||||
return process.platform === 'linux' && /\.AppImage$/i.test(appPath);
|
||||
}
|
||||
|
||||
function buildAppEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...baseEnv,
|
||||
SUBMINER_APP_LOG: getAppLogPath(),
|
||||
SUBMINER_MPV_LOG: getMpvLogPath(),
|
||||
};
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
clearTransportedAppArgs(env);
|
||||
Object.assign(env, extraEnv);
|
||||
const layers = env.VK_INSTANCE_LAYERS;
|
||||
if (typeof layers === 'string' && layers.trim().length > 0) {
|
||||
const filtered = layers
|
||||
@@ -1216,6 +1348,10 @@ export function buildConfiguredMpvDefaultArgs(
|
||||
const mpvArgs: string[] = [];
|
||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||
if (process.platform === 'darwin') {
|
||||
// macOS menu accelerators do not reach mpv script bindings unless disabled.
|
||||
mpvArgs.push('--macos-menu-shortcuts=no');
|
||||
}
|
||||
mpvArgs.push(...buildMpvBackendArgs(args, baseEnv));
|
||||
mpvArgs.push(...buildMpvLaunchModeArgs(args.launchMode));
|
||||
return mpvArgs;
|
||||
@@ -1229,6 +1365,14 @@ function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_ELECTRON_MENU_DIAGNOSTIC =
|
||||
'representedObject is not a WeakPtrToElectronMenuModelAsNSObject';
|
||||
|
||||
function filterKnownElectronDiagnostics(chunk: string): string {
|
||||
const lines = chunk.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
||||
return lines.filter((line) => !line.includes(KNOWN_ELECTRON_MENU_DIAGNOSTIC)).join('');
|
||||
}
|
||||
|
||||
function attachAppProcessLogging(
|
||||
proc: ReturnType<typeof spawn>,
|
||||
options?: {
|
||||
@@ -1243,8 +1387,12 @@ function attachAppProcessLogging(
|
||||
if (options?.mirrorStdout) process.stdout.write(chunk);
|
||||
});
|
||||
proc.stderr?.on('data', (chunk: string) => {
|
||||
appendCapturedAppOutput('STDERR', chunk);
|
||||
if (options?.mirrorStderr) process.stderr.write(chunk);
|
||||
const filteredChunk = filterKnownElectronDiagnostics(chunk);
|
||||
if (!filteredChunk) {
|
||||
return;
|
||||
}
|
||||
appendCapturedAppOutput('STDERR', filteredChunk);
|
||||
if (options?.mirrorStderr) process.stderr.write(filteredChunk);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1260,7 +1408,7 @@ function runSyncAppCommand(
|
||||
} {
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
const result = spawnSync(target.command, target.args, {
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.stdout) {
|
||||
@@ -1268,13 +1416,16 @@ function runSyncAppCommand(
|
||||
if (mirrorOutput) process.stdout.write(result.stdout);
|
||||
}
|
||||
if (result.stderr) {
|
||||
appendCapturedAppOutput('STDERR', result.stderr);
|
||||
if (mirrorOutput) process.stderr.write(result.stderr);
|
||||
const filteredStderr = filterKnownElectronDiagnostics(result.stderr);
|
||||
if (filteredStderr) {
|
||||
appendCapturedAppOutput('STDERR', filteredStderr);
|
||||
if (mirrorOutput) process.stderr.write(filteredStderr);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: result.status ?? 1,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
stderr: result.stderr ? filterKnownElectronDiagnostics(result.stderr) : '',
|
||||
error: result.error ?? undefined,
|
||||
};
|
||||
}
|
||||
@@ -1290,6 +1441,13 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean {
|
||||
}
|
||||
|
||||
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
|
||||
if (shouldTransportAppArgsForAppImage(appPath)) {
|
||||
return {
|
||||
command: appPath,
|
||||
args: [],
|
||||
env: buildTransportedAppArgsEnv(appArgs),
|
||||
};
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
return { command: appPath, args: appArgs };
|
||||
}
|
||||
@@ -1304,7 +1462,7 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
||||
proc.once('error', (error) => {
|
||||
@@ -1323,7 +1481,7 @@ export function runAppCommandSilently(appPath: string, appArgs: string[]): void
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(proc);
|
||||
proc.once('error', (error) => {
|
||||
@@ -1374,7 +1532,7 @@ export function runAppCommandAttached(
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
||||
proc.once('error', (error) => {
|
||||
@@ -1445,7 +1603,7 @@ export function launchAppCommandDetached(
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', stdoutFd, stderrFd],
|
||||
detached: true,
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
proc.once('error', (error) => {
|
||||
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
||||
@@ -1462,6 +1620,7 @@ export function launchMpvIdleDetached(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
runtimePluginPath?: string | null,
|
||||
runtimePluginConfig?: PluginRuntimeConfig,
|
||||
): Promise<void> {
|
||||
return (async () => {
|
||||
await terminateTrackedDetachedMpv(args.logLevel);
|
||||
@@ -1483,8 +1642,17 @@ export function launchMpvIdleDetached(
|
||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||
}
|
||||
mpvArgs.push('--idle=yes');
|
||||
const runtimeScriptOpts = runtimePluginConfig
|
||||
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
|
||||
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||
mpvArgs.push(
|
||||
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`,
|
||||
`--script-opts=${buildSubminerScriptOpts(
|
||||
appPath,
|
||||
socketPath,
|
||||
null,
|
||||
args.logLevel,
|
||||
runtimeScriptOpts,
|
||||
)}`,
|
||||
);
|
||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
|
||||
@@ -57,10 +57,10 @@ test('parseArgs captures mpv args string', () => {
|
||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||
});
|
||||
|
||||
test('parseArgs maps root config window option', () => {
|
||||
const parsed = parseArgs(['--config'], 'subminer', {});
|
||||
test('parseArgs maps root settings window option', () => {
|
||||
const parsed = parseArgs(['--settings'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.configSettings, true);
|
||||
assert.equal(parsed.settings, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
|
||||
@@ -107,10 +107,10 @@ test('parseArgs maps config show action', () => {
|
||||
assert.equal(parsed.configPath, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps bare config command to settings window', () => {
|
||||
const parsed = parseArgs(['config'], 'subminer', {});
|
||||
test('parseArgs maps settings command to settings window', () => {
|
||||
const parsed = parseArgs(['settings'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.configSettings, true);
|
||||
assert.equal(parsed.settings, true);
|
||||
assert.equal(parsed.configPath, false);
|
||||
assert.equal(parsed.configShow, false);
|
||||
});
|
||||
@@ -119,7 +119,7 @@ test('parseArgs maps config path action to config path output', () => {
|
||||
const parsed = parseArgs(['config', 'path'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.configPath, true);
|
||||
assert.equal(parsed.configSettings, false);
|
||||
assert.equal(parsed.settings, false);
|
||||
});
|
||||
|
||||
test('parseArgs rejects removed config open and launch actions', () => {
|
||||
@@ -134,6 +134,14 @@ test('parseArgs rejects removed config open and launch actions', () => {
|
||||
assert.equal(exit.code, 1);
|
||||
});
|
||||
|
||||
test('parseArgs requires an explicit action for the config subcommand', () => {
|
||||
const exit = withProcessExitIntercept(() => {
|
||||
parseArgs(['config'], 'subminer', {});
|
||||
});
|
||||
|
||||
assert.equal(exit.code, 1);
|
||||
});
|
||||
|
||||
test('parseArgs maps mpv idle action', () => {
|
||||
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
|
||||
|
||||
|
||||
+157
-14
@@ -58,14 +58,11 @@ function createSmokeCase(name: string): SmokeCase {
|
||||
|
||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(videoPath, 'fake video fixture');
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\n`,
|
||||
);
|
||||
|
||||
const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir });
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } }));
|
||||
const setupState = createDefaultSetupState();
|
||||
setupState.status = 'completed';
|
||||
setupState.completedAt = '2026-03-07T00:00:00.000Z';
|
||||
@@ -136,6 +133,9 @@ if (entry.argv.includes('--start')) {
|
||||
if (entry.argv.includes('--stop')) {
|
||||
fs.appendFileSync(stopPath, JSON.stringify(entry) + '\\n');
|
||||
}
|
||||
if (entry.argv.includes('--app-ping')) {
|
||||
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
`,
|
||||
@@ -238,6 +238,94 @@ async function waitForJsonLines(
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFile(filePath: string, timeoutMs = 1500): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(filePath)) return;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`Timed out waiting for file ${filePath} after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function startFakeControlServer(
|
||||
smokeCase: SmokeCase,
|
||||
): Promise<{ socketPath: string; logPath: string; stop: () => Promise<void> }> {
|
||||
const socketPath = path.join(smokeCase.socketDir, 'app-control.sock');
|
||||
const logPath = path.join(smokeCase.artifactsDir, 'fake-control.log');
|
||||
const readyPath = path.join(smokeCase.artifactsDir, 'fake-control.ready');
|
||||
const scriptPath = path.join(smokeCase.artifactsDir, 'fake-control-server.js');
|
||||
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
`const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const path = require('node:path');
|
||||
|
||||
const socketPath = ${JSON.stringify(socketPath)};
|
||||
const logPath = ${JSON.stringify(logPath)};
|
||||
const readyPath = ${JSON.stringify(readyPath)};
|
||||
try { fs.rmSync(socketPath, { force: true }); } catch {}
|
||||
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
let handledLine = false;
|
||||
while (true) {
|
||||
const newlineMatch = buffer.match(/\\r?\\n/);
|
||||
if (!newlineMatch || newlineMatch.index === undefined) break;
|
||||
const line = buffer.slice(0, newlineMatch.index).trim();
|
||||
buffer = buffer.slice(newlineMatch.index + newlineMatch[0].length);
|
||||
if (!line) continue;
|
||||
fs.appendFileSync(logPath, line + '\\n');
|
||||
handledLine = true;
|
||||
}
|
||||
if (handledLine) {
|
||||
socket.end(JSON.stringify({ ok: true }) + '\\n');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(socketPath, () => {
|
||||
fs.writeFileSync(readyPath, 'ready');
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
server.close(() => {
|
||||
try { fs.rmSync(socketPath, { force: true }); } catch {}
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
);
|
||||
|
||||
const proc = spawn(process.execPath, [scriptPath], { stdio: 'ignore' });
|
||||
await waitForFile(readyPath);
|
||||
|
||||
return {
|
||||
socketPath,
|
||||
logPath,
|
||||
stop: async () => {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
||||
proc.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
resolve();
|
||||
}, 1000);
|
||||
proc.once('close', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('launcher smoke fixture seeds completed setup state', () => {
|
||||
const smokeCase = createSmokeCase('setup-state');
|
||||
try {
|
||||
@@ -295,7 +383,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
|
||||
});
|
||||
|
||||
test(
|
||||
'launcher start-overlay run forwards socket/backend and keeps background app alive after mpv exits',
|
||||
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
|
||||
@@ -330,7 +418,9 @@ test(
|
||||
|
||||
const appStartArgs = appStartEntries[0]?.argv;
|
||||
assert.equal(Array.isArray(appStartArgs), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--background'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--start'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--managed-playback'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--backend'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('x11'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--socket'), true);
|
||||
@@ -350,20 +440,73 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'launcher start-overlay attaches to a running background app without spawning another app command',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
||||
const controlServer = await startFakeControlServer(smokeCase);
|
||||
const env = {
|
||||
...makeTestEnv(smokeCase),
|
||||
SUBMINER_FAKE_APP_RUNNING: '1',
|
||||
SUBMINER_APP_CONTROL_SOCKET: controlServer.socketPath,
|
||||
};
|
||||
try {
|
||||
const result = runLauncher(
|
||||
smokeCase,
|
||||
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
|
||||
env,
|
||||
'overlay-borrow-background',
|
||||
);
|
||||
|
||||
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
|
||||
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||
await waitForJsonLines(controlServer.logPath, 1);
|
||||
|
||||
const appEntries = readJsonLines(appLogPath);
|
||||
const appStartEntries = readJsonLines(appStartPath);
|
||||
const appStopEntries = readJsonLines(appStopPath);
|
||||
const controlEntries = readJsonLines(controlServer.logPath);
|
||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const mpvError = mpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(appEntries.length, 0);
|
||||
assert.equal(appStartEntries.length, 0);
|
||||
assert.equal(appStopEntries.length, 0);
|
||||
assert.equal(controlEntries.length, 1);
|
||||
const controlArgs = controlEntries[0]?.argv;
|
||||
assert.equal(Array.isArray(controlArgs), true);
|
||||
assert.equal((controlArgs as string[]).includes('--background'), false);
|
||||
assert.equal((controlArgs as string[]).includes('--start'), true);
|
||||
assert.equal((controlArgs as string[]).includes('--managed-playback'), true);
|
||||
} finally {
|
||||
await controlServer.stop();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
||||
fs.writeFileSync(
|
||||
path.join(smokeCase.xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
[
|
||||
`socket_path=${smokeCase.socketPath}`,
|
||||
'auto_start=yes',
|
||||
'auto_start_visible_overlay=yes',
|
||||
'auto_start_pause_until_ready=yes',
|
||||
'',
|
||||
].join('\n'),
|
||||
path.join(getDefaultConfigDir(smokeCase), 'config.jsonc'),
|
||||
JSON.stringify({
|
||||
auto_start_overlay: true,
|
||||
mpv: {
|
||||
socketPath: smokeCase.socketPath,
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const env = makeTestEnv(smokeCase);
|
||||
|
||||
+15
-6
@@ -1,15 +1,12 @@
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { MpvLaunchMode } from '../src/types/config.js';
|
||||
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
|
||||
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
|
||||
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
||||
|
||||
export const ROFI_THEME_FILE = 'subminer.rasi';
|
||||
export function getDefaultSocketPath(platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform === 'win32') {
|
||||
return '\\\\.\\pipe\\subminer-socket';
|
||||
}
|
||||
return '/tmp/subminer-socket';
|
||||
return platform === 'win32' ? '\\\\.\\pipe\\subminer-socket' : '/tmp/subminer-socket';
|
||||
}
|
||||
|
||||
export const DEFAULT_SOCKET_PATH = getDefaultSocketPath();
|
||||
@@ -136,7 +133,7 @@ export interface Args {
|
||||
doctorRefreshKnownWords: boolean;
|
||||
version: boolean;
|
||||
update?: boolean;
|
||||
configSettings: boolean;
|
||||
settings: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
@@ -178,13 +175,25 @@ export interface LauncherJellyfinConfig {
|
||||
|
||||
export interface LauncherMpvConfig {
|
||||
launchMode?: MpvLaunchMode;
|
||||
socketPath?: string;
|
||||
backend?: MpvBackend;
|
||||
autoStartSubMiner?: boolean;
|
||||
pauseUntilOverlayReady?: boolean;
|
||||
subminerBinaryPath?: string;
|
||||
aniskipEnabled?: boolean;
|
||||
aniskipButtonKey?: string;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeConfig {
|
||||
socketPath: string;
|
||||
binaryPath: string;
|
||||
backend: Backend;
|
||||
autoStart: boolean;
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
texthookerEnabled: boolean;
|
||||
aniskipEnabled: boolean;
|
||||
aniskipButtonKey: string;
|
||||
}
|
||||
|
||||
export interface CommandExecOptions {
|
||||
|
||||
Reference in New Issue
Block a user