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