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:
@@ -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
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user