fix: transport AppImage args via env and gate restart on app-ping

- Transport Linux AppImage CLI args through SUBMINER_APP_ARGC/ARG_* env vars instead of argv
- Add --app-ping command to probe single-instance lock ownership (exit 0 = running, 1 = not)
- Gate manual restart: poll app-ping until old app releases lock, then until new app owns it
- Preserve user-paused playback when disarming the auto-play-ready gate on restart
- Snapshot subtitles before connection side effects (sub-visibility hide) can suppress them
- Reapply overlay bounds after first show for Hyprland compatibility
This commit is contained in:
2026-05-18 01:29:35 -07:00
parent 48447c2f1a
commit 1bb7b26641
33 changed files with 923 additions and 66 deletions
+5
View File
@@ -236,6 +236,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(help), false);
assert.equal(shouldRunSettingsOnlyStartup(help), false);
const appPing = parseArgs(['--app-ping']);
assert.equal(appPing.appPing, true);
assert.equal(hasExplicitCommand(appPing), true);
assert.equal(shouldStartApp(appPing), false);
const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true);
+6
View File
@@ -74,6 +74,7 @@ export interface CliArgs {
texthooker: boolean;
texthookerOpenBrowser: boolean;
help: boolean;
appPing?: boolean;
update?: boolean;
updateLauncherPath?: string;
updateResponsePath?: string;
@@ -172,6 +173,7 @@ export function parseArgs(argv: string[]): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
appPing: false,
update: false,
updateLauncherPath: undefined,
updateResponsePath: undefined,
@@ -339,6 +341,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
else if (arg === '--texthooker') args.texthooker = true;
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
else if (arg === '--app-ping') args.appPing = true;
else if (arg === '--update') args.update = true;
else if (arg.startsWith('--update-launcher-path=')) {
const value = arg.split('=', 2)[1];
@@ -540,6 +543,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth ||
args.texthooker ||
args.appPing ||
args.update ||
args.generateConfig ||
args.help
@@ -612,6 +616,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.appPing &&
!args.update &&
!args.help &&
!args.autoStartOverlay &&
@@ -737,6 +742,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.texthooker &&
!args.appPing &&
!args.update &&
!args.help &&
!args.autoStartOverlay &&
+31
View File
@@ -69,6 +69,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
appPing: false,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -91,6 +92,9 @@ function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
quitApp: () => {
calls.push('quitApp');
},
exitApp: (code) => {
calls.push(`exit:${code}`);
},
onSecondInstance: () => {},
handleCliCommand: () => {},
printHelp: () => {
@@ -136,3 +140,30 @@ test('startAppLifecycle still acquires lock for startup commands', () => {
assert.equal(getLockCalls(), 1);
});
test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => {
const { deps, calls, getLockCalls } = createDeps({
shouldStartApp: () => false,
});
startAppLifecycle(makeArgs({ appPing: true }), deps);
assert.equal(getLockCalls(), 1);
assert.deepEqual(calls, ['exit:1']);
});
test('startAppLifecycle app ping exits zero immediately when another instance owns the lock', () => {
let lockCalls = 0;
const { deps, calls } = createDeps({
shouldStartApp: () => false,
requestSingleInstanceLock: () => {
lockCalls += 1;
return false;
},
});
startAppLifecycle(makeArgs({ appPing: true }), deps);
assert.equal(lockCalls, 1);
assert.deepEqual(calls, ['exit:0']);
});
+15
View File
@@ -8,6 +8,7 @@ export interface AppLifecycleServiceDeps {
parseArgs: (argv: string[]) => CliArgs;
requestSingleInstanceLock: () => boolean;
quitApp: () => void;
exitApp: (code: number) => void;
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void;
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
@@ -27,6 +28,7 @@ export interface AppLifecycleServiceDeps {
interface AppLike {
requestSingleInstanceLock: () => boolean;
quit: () => void;
exit?: (exitCode?: number) => void;
on: (...args: any[]) => unknown;
whenReady: () => Promise<void>;
}
@@ -54,6 +56,14 @@ export function createAppLifecycleDepsRuntime(
parseArgs: options.parseArgs,
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
quitApp: () => options.app.quit(),
exitApp: (code) => {
if (options.app.exit) {
options.app.exit(code);
return;
}
process.exitCode = code;
options.app.quit();
},
onSecondInstance: (handler) => {
options.app.on('second-instance', handler as (...args: unknown[]) => void);
},
@@ -94,6 +104,11 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
}
const gotTheLock = deps.requestSingleInstanceLock();
if (initialArgs.appPing) {
deps.exitApp(gotTheLock ? 1 : 0);
return;
}
if (!gotTheLock) {
deps.quitApp();
return;
+35
View File
@@ -385,6 +385,41 @@ test('MpvIpcClient connect does not force primary subtitle visibility from bindi
assert.equal(hasPrimaryVisibilityMutation, false);
});
test('MpvIpcClient snapshots current subtitles before connection side effects can hide them', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = (command: unknown) => {
commands.push(command);
return true;
};
client.on('connection-change', ({ connected }) => {
if (connected) {
client.setSubVisibility(false);
}
});
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
const firstSubTextSnapshot = commands.findIndex((command) => {
const args = (command as { command?: unknown[] }).command;
return Array.isArray(args) && args[0] === 'get_property' && args[1] === 'sub-text';
});
const firstPrimaryHide = commands.findIndex((command) => {
const args = (command as { command?: unknown[] }).command;
return (
Array.isArray(args) &&
args[0] === 'set_property' &&
args[1] === 'sub-visibility' &&
(args[2] === false || args[2] === 'no')
);
});
assert.notEqual(firstSubTextSnapshot, -1);
assert.notEqual(firstPrimaryHide, -1);
assert.ok(firstSubTextSnapshot < firstPrimaryHide);
});
test('MpvIpcClient setSubVisibility writes compatibility commands for visibility toggle', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
+1 -1
View File
@@ -186,12 +186,12 @@ export class MpvIpcClient implements MpvClient {
this.connected = true;
this.connecting = false;
this.socket = this.transport.getSocket();
this.emit('connection-change', { connected: true });
this.reconnectAttempt = 0;
this.hasConnectedOnce = true;
this.setSecondarySubVisibility(false);
subscribeToMpvProperties(this.send.bind(this));
requestMpvInitialState(this.send.bind(this));
this.emit('connection-change', { connected: true });
const shouldAutoStart =
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
@@ -13,15 +13,26 @@ type WindowTrackerStub = {
function createMainWindowRecorder() {
const calls: string[] = [];
const listeners = new Map<string, Array<() => void>>();
let visible = false;
let focused = false;
let opacity = 1;
let contentReady = true;
const emit = (event: string): void => {
const handlers = listeners.get(event) ?? [];
listeners.delete(event);
for (const handler of handlers) {
handler();
}
};
const window = {
webContents: {},
isDestroyed: () => false,
isVisible: () => visible,
isFocused: () => focused,
once: (event: string, handler: () => void) => {
listeners.set(event, [...(listeners.get(event) ?? []), handler]);
},
hide: () => {
visible = false;
focused = false;
@@ -30,10 +41,12 @@ function createMainWindowRecorder() {
show: () => {
visible = true;
calls.push('show');
emit('show');
},
showInactive: () => {
visible = true;
calls.push('show-inactive');
emit('show');
},
focus: () => {
focused = true;
@@ -216,6 +229,44 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
assert.ok(!calls.includes('osd'));
});
test('tracked non-macOS overlay reapplies bounds after first show', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.deepEqual(
calls.filter((call) => call === 'update-bounds' || call === 'show'),
['update-bounds', 'show', 'update-bounds'],
);
});
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
+18
View File
@@ -270,6 +270,23 @@ export function updateVisibleOverlayVisibility(args: {
args.markOverlayLoadingOsdShown?.();
};
const refreshNonNativeOverlayBoundsAfterFirstShow = (geometry: WindowGeometry | null): void => {
if (
geometry === null ||
args.isMacOSPlatform ||
args.isWindowsPlatform ||
mainWindow.isVisible()
) {
return;
}
mainWindow.once('show', () => {
if (mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return;
}
args.updateVisibleOverlayBounds(geometry);
});
};
if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false);
args.resetOverlayLoadingOsdSuppression?.();
@@ -298,6 +315,7 @@ export function updateVisibleOverlayVisibility(args: {
const geometry = args.windowTracker.getGeometry();
if (geometry) {
args.updateVisibleOverlayBounds(geometry);
refreshNonNativeOverlayBoundsAfterFirstShow(geometry);
}
args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
@@ -14,6 +14,33 @@ test('overlay window config explicitly disables renderer sandbox for preload com
assert.equal(options.webPreferences?.backgroundThrottling, false);
});
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
const modalOptions = buildOverlayWindowOptions('modal', {
isDev: false,
yomitanSession: null,
});
assert.equal(visibleOptions.resizable, true);
assert.equal(modalOptions.resizable, false);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
+2 -1
View File
@@ -16,6 +16,7 @@ export function buildOverlayWindowOptions(
): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
const shouldAllowCompositorResize = process.platform === 'linux' && kind === 'visible';
return {
show: false,
@@ -29,7 +30,7 @@ export function buildOverlayWindowOptions(
frame: false,
alwaysOnTop: shouldStartAlwaysOnTop,
skipTaskbar: true,
resizable: false,
resizable: shouldAllowCompositorResize,
hasShadow: false,
focusable: true,
acceptFirstMouse: true,
@@ -122,6 +122,35 @@ test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 ex
assert.equal(result[3]?.frequencyRank, 11);
});
test('annotateTokens keeps frequency for determiner-led content noun compounds', () => {
const tokens = [
makeToken({
surface: 'その場',
headword: 'その場',
reading: 'そのば',
partOfSpeech: PartOfSpeech.noun,
pos1: '連体詞|名詞',
pos2: '*|一般',
startPos: 0,
endPos: 3,
frequencyRank: 879,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'その場',
getJlptLevel: (text) => (text === 'その場' ? 'N4' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.frequencyRank, 879);
assert.equal(result[0]?.jlptLevel, 'N4');
});
test('annotateTokens preserves existing frequency rank when frequency is enabled', () => {
const tokens = [makeToken({ surface: '猫', headword: '猫', frequencyRank: 42 })];
@@ -188,6 +188,35 @@ function shouldAllowHonorificPrefixNounFrequency(token: MergedToken): boolean {
);
}
function shouldAllowDeterminerLedNounFrequency(
normalizedPos1: string,
normalizedPos2: string,
pos1Exclusions: ReadonlySet<string>,
pos2Exclusions: ReadonlySet<string>,
): boolean {
const pos1Parts = splitNormalizedTagParts(normalizedPos1);
if (pos1Parts.length < 2 || pos1Parts[0] !== '連体詞') {
return false;
}
const pos2Parts = splitNormalizedTagParts(normalizedPos2);
if (!isExcludedComponent(pos1Parts[0], pos2Parts[0], pos1Exclusions, pos2Exclusions)) {
return false;
}
const componentCount = Math.max(pos1Parts.length, pos2Parts.length);
for (let index = 1; index < componentCount; index += 1) {
if (
pos1Parts[index] === '名詞' &&
!isExcludedComponent(pos1Parts[index], pos2Parts[index], pos1Exclusions, pos2Exclusions)
) {
return true;
}
}
return false;
}
function isFrequencyExcludedByPos(
token: MergedToken,
pos1Exclusions: ReadonlySet<string>,
@@ -207,12 +236,19 @@ function isFrequencyExcludedByPos(
pos1Exclusions,
pos2Exclusions,
);
const allowDeterminerLedNounToken = shouldAllowDeterminerLedNounFrequency(
normalizedPos1,
normalizedPos2,
pos1Exclusions,
pos2Exclusions,
);
const allowOrdinalPrefixNounToken = shouldAllowOrdinalPrefixNounFrequency(token);
const allowHonorificPrefixNounToken = shouldAllowHonorificPrefixNounFrequency(token);
if (
isExcludedByTagSet(normalizedPos1, pos1Exclusions) &&
!allowContentLedMergedToken &&
!allowDeterminerLedNounToken &&
!allowOrdinalPrefixNounToken &&
!allowHonorificPrefixNounToken
) {
@@ -222,6 +258,7 @@ function isFrequencyExcludedByPos(
if (
isExcludedByTagSet(normalizedPos2, pos2Exclusions) &&
!allowContentLedMergedToken &&
!allowDeterminerLedNounToken &&
!allowOrdinalPrefixNounToken &&
!allowHonorificPrefixNounToken
) {
+17
View File
@@ -14,6 +14,7 @@ import {
shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry,
hasTransportedStartupArgs,
} from './main-entry-runtime';
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
@@ -55,6 +56,22 @@ test('normalizeStartupArgv defaults no-arg Windows startup to --start only', ()
}
});
test('normalizeStartupArgv uses transported AppImage args instead of raw Electron args', () => {
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage', '--background'], {
SUBMINER_APP_ARGC: '2',
SUBMINER_APP_ARG_0: '--stop',
SUBMINER_APP_ARG_1: '--socket',
}),
['SubMiner.AppImage', '--stop', '--socket'],
);
});
test('hasTransportedStartupArgs detects env-carried app args', () => {
assert.equal(hasTransportedStartupArgs({ SUBMINER_APP_ARGC: '1' }), true);
assert.equal(hasTransportedStartupArgs({}), false);
});
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
+34
View File
@@ -7,6 +7,9 @@ const BACKGROUND_ARG = '--background';
const START_ARG = '--start';
const PASSWORD_STORE_ARG = '--password-store';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
const MAX_TRANSPORTED_APP_ARGS = 256;
const APP_NAME = 'SubMiner';
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
'--alang',
@@ -83,9 +86,40 @@ function parseCliArgs(argv: string[]): CliArgs {
return parseArgs(argv);
}
export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string';
}
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
if (rawCount === undefined) {
return null;
}
const count = Number(rawCount);
if (!Number.isInteger(count) || count < 0 || count > MAX_TRANSPORTED_APP_ARGS) {
return null;
}
const args: string[] = [];
for (let index = 0; index < count; index += 1) {
const value = env[`${TRANSPORTED_APP_ARG_PREFIX}${index}`];
if (typeof value !== 'string') {
return null;
}
args.push(value);
}
return args;
}
export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): string[] {
if (env.ELECTRON_RUN_AS_NODE === '1') return argv;
const transportedArgs = readTransportedStartupArgs(env);
if (transportedArgs) {
return [argv[0] ?? APP_NAME, ...transportedArgs];
}
const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
if (effectiveArgs.length === 0) {
if (process.platform === 'win32') {
+3 -1
View File
@@ -13,6 +13,7 @@ import {
sanitizeBackgroundEnv,
sanitizeHelpEnv,
sanitizeLaunchMpvEnv,
hasTransportedStartupArgs,
shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry,
@@ -184,7 +185,8 @@ registerFatalErrorHandlers({
});
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const child = spawn(process.execPath, process.argv.slice(1), {
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
const child = spawn(process.execPath, childArgs, {
detached: true,
stdio: 'ignore',
env: sanitizeBackgroundEnv(process.env),
+2 -1
View File
@@ -732,6 +732,7 @@ type BootServices = MainBootServicesResult<
{
requestSingleInstanceLock: () => boolean;
quit: () => void;
exit: (code?: number) => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>;
}
@@ -3435,7 +3436,7 @@ const {
stopConfigHotReload: () => configHotReloadRuntime.stop(),
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles();
restoreOverlayMpvSubtitles({ force: true });
},
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
stopSubtitleWebsocket: () => {
+6 -1
View File
@@ -6,6 +6,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
type MockAppLifecycleApp = {
requestSingleInstanceLock: () => boolean;
quit: () => void;
exit: (code?: number) => void;
on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp;
whenReady: () => Promise<void>;
};
@@ -54,6 +55,9 @@ test('createMainBootServices builds boot-phase service bundle', () => {
setPathValue = value;
},
quit: () => {},
exit: (code?: number) => {
calls.push(`exit:${code ?? 0}`);
},
on: (event: string) => {
appOnCalls.push(event);
return {};
@@ -123,8 +127,9 @@ test('createMainBootServices builds boot-phase service bundle', () => {
services.appLifecycleApp.on('second-instance', () => {}),
services.appLifecycleApp,
);
services.appLifecycleApp.exit(7);
assert.deepEqual(appOnCalls, ['ready']);
assert.equal(secondInstanceHandlerRegistered, true);
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
assert.equal(setPathValue, '/tmp/subminer-config');
});
+3
View File
@@ -4,6 +4,7 @@ import { ConfigStartupParseError } from '../../config';
export interface AppLifecycleShape {
requestSingleInstanceLock: () => boolean;
quit: () => void;
exit: (code?: number) => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>;
}
@@ -50,6 +51,7 @@ export interface MainBootServicesParams<
app: {
setPath: (name: string, value: string) => void;
quit: () => void;
exit: (code?: number) => void;
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
on: Function;
whenReady: () => Promise<void>;
@@ -260,6 +262,7 @@ export function createMainBootServices<
requestSingleInstanceLock: () =>
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
quit: () => params.app.quit(),
exit: (code?: number) => params.app.exit(code),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {
params.registerSecondInstanceHandlerEarly(
+40 -5
View File
@@ -9,22 +9,40 @@ import * as earlySingleInstance from './early-single-instance';
function createFakeApp(lockValue = true) {
let requestCalls = 0;
let secondInstanceListener: ((_event: unknown, argv: string[]) => void) | null = null;
let requestData: unknown = null;
let secondInstanceListener:
| ((
_event: unknown,
argv: string[],
workingDirectory?: string,
additionalData?: unknown,
) => void)
| null = null;
return {
app: {
requestSingleInstanceLock: () => {
requestSingleInstanceLock: (additionalData?: unknown) => {
requestCalls += 1;
requestData = additionalData ?? null;
return lockValue;
},
on: (_event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => {
on: (
_event: 'second-instance',
listener: (
_event: unknown,
argv: string[],
workingDirectory?: string,
additionalData?: unknown,
) => void,
) => {
secondInstanceListener = listener;
},
},
emitSecondInstance: (argv: string[]) => {
secondInstanceListener?.({}, argv);
emitSecondInstance: (argv: string[], additionalData?: unknown) => {
secondInstanceListener?.({}, argv, '/tmp', additionalData);
},
getRequestCalls: () => requestCalls,
getRequestData: () => requestData,
};
}
@@ -56,6 +74,23 @@ test('registerSecondInstanceHandlerEarly replays queued argv and forwards new ev
]);
});
test('requestSingleInstanceLockEarly sends normalized argv through second-instance data', () => {
resetEarlySingleInstanceStateForTests();
const fake = createFakeApp(true);
const primaryArgv = ['SubMiner.AppImage', '--start'];
const transportedArgv = ['SubMiner.AppImage', '--stop'];
const calls: string[][] = [];
assert.equal(requestSingleInstanceLockEarly(fake.app, primaryArgv), true);
registerSecondInstanceHandlerEarly(fake.app, (_event, argv) => {
calls.push(argv);
});
fake.emitSecondInstance(['SubMiner.AppImage'], { subminerArgv: transportedArgv });
assert.deepEqual(fake.getRequestData(), { subminerArgv: primaryArgv });
assert.deepEqual(calls, [transportedArgv]);
});
test('stats daemon args bypass the normal single-instance lock path', () => {
const shouldBypass = (
earlySingleInstance as typeof earlySingleInstance & {
+35 -6
View File
@@ -1,8 +1,18 @@
interface ElectronSecondInstanceAppLike {
requestSingleInstanceLock: () => boolean;
on: (event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => unknown;
requestSingleInstanceLock: (additionalData?: Record<string, unknown>) => boolean;
on: (
event: 'second-instance',
listener: (
_event: unknown,
argv: string[],
workingDirectory?: string,
additionalData?: unknown,
) => void,
) => unknown;
}
const SECOND_INSTANCE_ARGV_KEY = 'subminerArgv';
export function shouldBypassSingleInstanceLockForArgv(argv: readonly string[]): boolean {
return argv.includes('--stats-background') || argv.includes('--stats-stop');
}
@@ -12,10 +22,24 @@ let secondInstanceListenerAttached = false;
const secondInstanceArgvHistory: string[][] = [];
const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>();
function normalizeSecondInstanceArgv(argv: string[], additionalData: unknown): string[] {
if (
additionalData &&
typeof additionalData === 'object' &&
Array.isArray((additionalData as { subminerArgv?: unknown }).subminerArgv) &&
(additionalData as { subminerArgv: unknown[] }).subminerArgv.every(
(value) => typeof value === 'string',
)
) {
return [...(additionalData as { subminerArgv: string[] }).subminerArgv];
}
return [...argv];
}
function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void {
if (secondInstanceListenerAttached) return;
app.on('second-instance', (event, argv) => {
const clonedArgv = [...argv];
app.on('second-instance', (event, argv, _workingDirectory, additionalData) => {
const clonedArgv = normalizeSecondInstanceArgv(argv, additionalData);
secondInstanceArgvHistory.push(clonedArgv);
for (const handler of secondInstanceHandlers) {
handler(event, [...clonedArgv]);
@@ -24,12 +48,17 @@ function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void
secondInstanceListenerAttached = true;
}
export function requestSingleInstanceLockEarly(app: ElectronSecondInstanceAppLike): boolean {
export function requestSingleInstanceLockEarly(
app: ElectronSecondInstanceAppLike,
argv: readonly string[] = process.argv,
): boolean {
attachSecondInstanceListener(app);
if (cachedSingleInstanceLock !== null) {
return cachedSingleInstanceLock;
}
cachedSingleInstanceLock = app.requestSingleInstanceLock();
cachedSingleInstanceLock = app.requestSingleInstanceLock({
[SECOND_INSTANCE_ARGV_KEY]: [...argv],
});
return cachedSingleInstanceLock;
}
@@ -104,6 +104,36 @@ test('restore keeps mpv subtitles hidden when visible-overlay binding still requ
assert.deepEqual(calls, [false]);
});
test('forced restore ignores visible-overlay suppression during app shutdown', () => {
const state: VisibilityState = {
savedSubVisibility: true,
revision: 9,
};
const calls: boolean[] = [];
const restore = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => state.savedSubVisibility,
setSavedSubVisibility: (visible) => {
state.savedSubVisibility = visible;
},
getRevision: () => state.revision,
setRevision: (revision) => {
state.revision = revision;
},
isMpvConnected: () => true,
shouldKeepSuppressedFromVisibleOverlayBinding: () => true,
setMpvSubVisibility: (visible) => {
calls.push(visible);
},
});
restore({ force: true });
assert.equal(state.savedSubVisibility, null);
assert.equal(state.revision, 10);
assert.deepEqual(calls, [true]);
});
test('restore defers mpv subtitle restore while mpv is disconnected', () => {
const state: VisibilityState = {
savedSubVisibility: true,
@@ -3,6 +3,10 @@ type MpvVisibilityClient = {
requestProperty: (name: string) => Promise<unknown>;
};
type RestoreOverlayMpvSubtitlesOptions = {
force?: boolean;
};
function parseSubVisibility(value: unknown): boolean {
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
@@ -81,11 +85,11 @@ export function createRestoreOverlayMpvSubtitlesHandler(deps: {
shouldKeepSuppressedFromVisibleOverlayBinding: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
}) {
return (): void => {
return (options: RestoreOverlayMpvSubtitlesOptions = {}): void => {
deps.setRevision(deps.getRevision() + 1);
const savedVisibility = deps.getSavedSubVisibility();
if (deps.shouldKeepSuppressedFromVisibleOverlayBinding()) {
if (!options.force && deps.shouldKeepSuppressedFromVisibleOverlayBinding()) {
deps.setMpvSubVisibility(false);
return;
}
@@ -101,6 +101,8 @@ test('createCurlFetch requests updater metadata without Electron networking', as
'--show-error',
'--connect-timeout',
'30',
'--max-time',
'60',
'--header',
'Accept: application/vnd.github+json',
'--header',
@@ -108,4 +110,5 @@ test('createCurlFetch requests updater metadata without Electron networking', as
'https://api.github.com/repos/ksyasuda/SubMiner/releases',
]);
assert.equal(calls[0]?.options.encoding, 'buffer');
assert.equal(calls[0]?.options.timeout, 65_000);
});
+11 -1
View File
@@ -67,7 +67,16 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
const curlPath = options.curlPath ?? '/usr/bin/curl';
return async (url, init = {}) => {
const args = ['--fail', '--location', '--silent', '--show-error', '--connect-timeout', '30'];
const args = [
'--fail',
'--location',
'--silent',
'--show-error',
'--connect-timeout',
'30',
'--max-time',
'60',
];
addHeaderArgs(args, init.headers);
args.push(url);
const body = await new Promise<Buffer>((resolve, reject) => {
@@ -77,6 +86,7 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
{
encoding: 'buffer',
maxBuffer: 600 * 1024 * 1024,
timeout: 65_000,
},
(error, stdout, stderr) => {
if (error) {
@@ -165,3 +165,32 @@ test('yomitan extension runtime notifies once after concurrent ensure load resol
assert.equal(await second, fakeExtension);
assert.deepEqual(notifications, [fakeExtension]);
});
test('yomitan extension runtime retries notification after callback failure', async () => {
const fakeExtension = { id: 'yomitan' } as Extension;
let calls = 0;
const runtime = createYomitanExtensionRuntime({
loadYomitanExtensionCore: async () => fakeExtension,
userDataPath: '/tmp',
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {},
setYomitanExtension: () => {},
setYomitanSession: () => {},
getYomitanExtension: () => fakeExtension,
getLoadInFlight: () => null,
setLoadInFlight: () => {},
onYomitanExtensionLoaded: () => {
calls += 1;
if (calls === 1) {
throw new Error('overlay reload failed');
}
},
});
await assert.rejects(runtime.ensureYomitanExtensionLoaded(), /overlay reload failed/);
assert.equal(await runtime.ensureYomitanExtensionLoaded(), fakeExtension);
assert.equal(calls, 2);
});
+19 -2
View File
@@ -50,12 +50,29 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps)
);
let lastNotifiedExtension: Extension | null = null;
let notifyingExtension: Extension | null = null;
let notificationPromise: Promise<void> | null = null;
async function notifyYomitanExtensionLoaded(extension: Extension | null): Promise<void> {
if (!extension || extension === lastNotifiedExtension) {
return;
}
lastNotifiedExtension = extension;
await deps.onYomitanExtensionLoaded?.(extension);
if (extension === notifyingExtension && notificationPromise) {
await notificationPromise;
return;
}
notifyingExtension = extension;
notificationPromise = (async () => {
await deps.onYomitanExtensionLoaded?.(extension);
lastNotifiedExtension = extension;
})();
try {
await notificationPromise;
} finally {
if (notifyingExtension === extension) {
notifyingExtension = null;
notificationPromise = null;
}
}
}
return {