mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101)
This commit is contained in:
@@ -143,6 +143,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
activatePlaybackWindowForOverlayInteraction: () => false,
|
||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
@@ -247,6 +248,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getMpvClient: () => null,
|
||||
focusMainWindow: () => {},
|
||||
activatePlaybackWindowForOverlayInteraction: () => false,
|
||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => ({}),
|
||||
@@ -312,6 +314,28 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
assert.equal(deps.getPlaybackPaused(), true);
|
||||
});
|
||||
|
||||
test('createIpcDepsRuntime ignores overlay content reports from stale visible renderers', () => {
|
||||
const mainWindow = { id: 'main', isDestroyed: () => false } as never;
|
||||
const staleWindow = { id: 'stale', isDestroyed: () => false } as never;
|
||||
const reports: unknown[] = [];
|
||||
const deps = createIpcDepsRuntime({
|
||||
getMainWindow: () => mainWindow,
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
reports.push(payload);
|
||||
},
|
||||
} as unknown as Parameters<typeof createIpcDepsRuntime>[0]);
|
||||
|
||||
const report = deps.reportOverlayContentBounds as (
|
||||
payload: unknown,
|
||||
senderWindow: unknown,
|
||||
) => void;
|
||||
report({ source: 'stale' }, staleWindow);
|
||||
report({ source: 'main' }, mainWindow);
|
||||
report({ source: 'missing' }, null);
|
||||
|
||||
assert.deepEqual(reports, [{ source: 'main' }]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
@@ -334,6 +358,27 @@ test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction activ
|
||||
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers passes sender window to overlay content bounds reports', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const senderWindows: unknown[] = [];
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
reportOverlayContentBounds: ((_payload: unknown, senderWindow: unknown) => {
|
||||
senderWindows.push(senderWindow);
|
||||
}) as IpcServiceDeps['reportOverlayContentBounds'],
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.on.get(IPC_CHANNELS.command.reportOverlayContentBounds);
|
||||
assert.equal(typeof handler, 'function');
|
||||
|
||||
handler?.({}, { layer: 'visible' });
|
||||
|
||||
assert.deepEqual(senderWindows, [null]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
@@ -608,6 +653,27 @@ test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () =
|
||||
assert.deepEqual(await handler!({}), snapshot);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers exposes playback window activation request', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
activatePlaybackWindowForOverlayInteraction: async () => {
|
||||
calls.push('activate');
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.handle.get(
|
||||
IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction,
|
||||
);
|
||||
assert.ok(handler);
|
||||
assert.equal(await handler!({}), true);
|
||||
assert.deepEqual(calls, ['activate']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -49,6 +49,10 @@ export interface IpcServiceDeps {
|
||||
active: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
onOverlayInteractiveHint?: (
|
||||
interactive: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
@@ -58,7 +62,8 @@ export interface IpcServiceDeps {
|
||||
getCurrentSubtitleRaw: () => string;
|
||||
getCurrentSubtitleAss: () => string;
|
||||
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getSubtitleSidebarOpen?: () => boolean;
|
||||
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
|
||||
getSubtitlePosition: () => unknown;
|
||||
getSubtitleStyle: () => unknown;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
@@ -81,6 +86,7 @@ export interface IpcServiceDeps {
|
||||
getSecondarySubMode: () => unknown;
|
||||
getCurrentSecondarySub: () => string;
|
||||
focusMainWindow: () => void;
|
||||
activatePlaybackWindowForOverlayInteraction?: () => boolean | Promise<boolean>;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
onYoutubePickerResolve: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
@@ -89,7 +95,10 @@ export interface IpcServiceDeps {
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
reportOverlayContentBounds: (
|
||||
payload: unknown,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
getAnilistStatus: () => unknown;
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
@@ -229,6 +238,10 @@ export interface IpcDepsRuntimeOptions {
|
||||
active: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
onOverlayInteractiveHint?: (
|
||||
interactive: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
@@ -236,7 +249,8 @@ export interface IpcDepsRuntimeOptions {
|
||||
getCurrentSubtitleRaw: () => string;
|
||||
getCurrentSubtitleAss: () => string;
|
||||
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getSubtitleSidebarOpen?: () => boolean;
|
||||
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
|
||||
getSubtitlePosition: () => unknown;
|
||||
getSubtitleStyle: () => unknown;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
@@ -254,6 +268,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
getSecondarySubMode: () => unknown;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
focusMainWindow: () => void;
|
||||
activatePlaybackWindowForOverlayInteraction?: () => boolean | Promise<boolean>;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
onYoutubePickerResolve: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
@@ -296,6 +311,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
onOverlayModalClosed: options.onOverlayModalClosed,
|
||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
|
||||
onOverlayInteractiveHint: options.onOverlayInteractiveHint,
|
||||
openYomitanSettings: options.openYomitanSettings,
|
||||
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
|
||||
quitApp: options.quitApp,
|
||||
@@ -310,6 +326,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
|
||||
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
||||
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
|
||||
getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false),
|
||||
getPlaybackPaused: options.getPlaybackPaused,
|
||||
getSubtitlePosition: options.getSubtitlePosition,
|
||||
getSubtitleStyle: options.getSubtitleStyle,
|
||||
@@ -342,13 +359,21 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.focus();
|
||||
},
|
||||
activatePlaybackWindowForOverlayInteraction:
|
||||
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
|
||||
runSubsyncManual: options.runSubsyncManual,
|
||||
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
||||
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
||||
getRuntimeOptions: options.getRuntimeOptions,
|
||||
setRuntimeOption: options.setRuntimeOption,
|
||||
cycleRuntimeOption: options.cycleRuntimeOption,
|
||||
reportOverlayContentBounds: options.reportOverlayContentBounds,
|
||||
reportOverlayContentBounds: (payload, senderWindow) => {
|
||||
const mainWindow = options.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (!senderWindow || senderWindow !== (mainWindow as unknown as ElectronBrowserWindow))
|
||||
return;
|
||||
options.reportOverlayContentBounds(payload);
|
||||
},
|
||||
getAnilistStatus: options.getAnilistStatus,
|
||||
clearAnilistToken: options.clearAnilistToken,
|
||||
openAnilistSetup: options.openAnilistSetup,
|
||||
@@ -526,6 +551,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return await deps.getSubtitleSidebarSnapshot();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarOpen, () => {
|
||||
return deps.getSubtitleSidebarOpen?.() ?? false;
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
|
||||
return deps.getPlaybackPaused();
|
||||
});
|
||||
@@ -628,6 +657,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
deps.focusMainWindow();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction, async () => {
|
||||
return (await deps.activatePlaybackWindowForOverlayInteraction?.()) ?? false;
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.runSubsyncManual, async (_event, request: unknown) => {
|
||||
const parsedRequest = parseSubsyncManualRunRequest(request);
|
||||
if (!parsedRequest) {
|
||||
@@ -668,8 +701,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.cycleRuntimeOption(parsedId, parsedDirection);
|
||||
});
|
||||
|
||||
ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (_event: unknown, payload: unknown) => {
|
||||
deps.reportOverlayContentBounds(payload);
|
||||
ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (event: unknown, payload: unknown) => {
|
||||
const senderWindow =
|
||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||
deps.reportOverlayContentBounds(payload, senderWindow);
|
||||
});
|
||||
|
||||
ipc.on(IPC_CHANNELS.command.reportOverlayInteractive, (event: unknown, interactive: unknown) => {
|
||||
if (typeof interactive !== 'boolean') return;
|
||||
const senderWindow =
|
||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||
deps.onOverlayInteractiveHint?.(interactive, senderWindow);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
|
||||
|
||||
@@ -58,6 +58,50 @@ test('overlay measurement store keeps latest payload for visible layer', () => {
|
||||
assert.equal(store.getLatestByLayer('visible')?.contentRect?.width, 400);
|
||||
});
|
||||
|
||||
test('overlay measurement store clears stale visible measurements', () => {
|
||||
const store = createOverlayContentMeasurementStore({
|
||||
now: () => 1000,
|
||||
warn: () => {
|
||||
// noop
|
||||
},
|
||||
});
|
||||
|
||||
store.report({
|
||||
layer: 'visible',
|
||||
measuredAtMs: 900,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
contentRect: { x: 50, y: 60, width: 400, height: 80 },
|
||||
interactiveRects: [{ x: 50, y: 60, width: 400, height: 80 }],
|
||||
});
|
||||
|
||||
assert.notEqual(store.getLatestByLayer('visible'), null);
|
||||
|
||||
store.clear('visible');
|
||||
|
||||
assert.equal(store.getLatestByLayer('visible'), null);
|
||||
});
|
||||
|
||||
test('sanitizeOverlayContentMeasurement preserves separate interactive rects', () => {
|
||||
const measurement = sanitizeOverlayContentMeasurement(
|
||||
{
|
||||
layer: 'visible',
|
||||
measuredAtMs: 100,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 50, y: 60, width: 400, height: 80 },
|
||||
interactiveRects: [
|
||||
{ x: 50, y: 60, width: 400, height: 80 },
|
||||
{ x: 100, y: 900, width: 500, height: 90 },
|
||||
],
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
assert.deepEqual(measurement?.interactiveRects, [
|
||||
{ x: 50, y: 60, width: 400, height: 80 },
|
||||
{ x: 100, y: 900, width: 500, height: 90 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay measurement store rate-limits invalid payload warnings', () => {
|
||||
let now = 1_000;
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -5,6 +5,7 @@ const logger = createLogger('main:overlay-content-measurement');
|
||||
const MAX_VIEWPORT = 10000;
|
||||
const MAX_RECT_DIMENSION = 10000;
|
||||
const MAX_RECT_OFFSET = 50000;
|
||||
const MAX_INTERACTIVE_RECTS = 8;
|
||||
const MAX_FUTURE_TIMESTAMP_MS = 60_000;
|
||||
const INVALID_LOG_THROTTLE_MS = 10_000;
|
||||
|
||||
@@ -26,6 +27,7 @@ export function sanitizeOverlayContentMeasurement(
|
||||
width?: unknown;
|
||||
height?: unknown;
|
||||
} | null;
|
||||
interactiveRects?: unknown;
|
||||
};
|
||||
|
||||
if (candidate.layer !== 'visible') {
|
||||
@@ -53,11 +55,21 @@ export function sanitizeOverlayContentMeasurement(
|
||||
return null;
|
||||
}
|
||||
|
||||
let interactiveRects: OverlayContentRect[] | undefined;
|
||||
if (candidate.interactiveRects !== undefined) {
|
||||
const sanitizedRects = sanitizeOverlayInteractiveRects(candidate.interactiveRects);
|
||||
if (!sanitizedRects) {
|
||||
return null;
|
||||
}
|
||||
interactiveRects = sanitizedRects;
|
||||
}
|
||||
|
||||
return {
|
||||
layer: candidate.layer,
|
||||
measuredAtMs,
|
||||
viewport: { width: viewportWidth, height: viewportHeight },
|
||||
contentRect,
|
||||
...(interactiveRects !== undefined ? { interactiveRects } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +106,22 @@ function sanitizeOverlayContentRect(rect: unknown): OverlayContentRect | null {
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function sanitizeOverlayInteractiveRects(rects: unknown): OverlayContentRect[] | null {
|
||||
if (!Array.isArray(rects) || rects.length > MAX_INTERACTIVE_RECTS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitized: OverlayContentRect[] = [];
|
||||
for (const rect of rects) {
|
||||
const sanitizedRect = sanitizeOverlayContentRect(rect);
|
||||
if (!sanitizedRect) {
|
||||
return null;
|
||||
}
|
||||
sanitized.push(sanitizedRect);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function readFiniteInRange(value: unknown, min: number, max: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return Number.NaN;
|
||||
@@ -140,7 +168,12 @@ export function createOverlayContentMeasurementStore(options?: {
|
||||
return latestByLayer[layer];
|
||||
}
|
||||
|
||||
function clear(layer: OverlayLayer): void {
|
||||
latestByLayer[layer] = null;
|
||||
}
|
||||
|
||||
return {
|
||||
clear,
|
||||
getLatestByLayer,
|
||||
report,
|
||||
};
|
||||
|
||||
@@ -62,6 +62,9 @@ function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {
|
||||
setAlwaysOnTop: (flag: boolean) => {
|
||||
calls.push(`always-on-top:${flag}`);
|
||||
},
|
||||
setFullScreen: (fullscreen: boolean) => {
|
||||
calls.push(`fullscreen:${fullscreen}`);
|
||||
},
|
||||
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
|
||||
calls.push(
|
||||
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
|
||||
@@ -259,6 +262,50 @@ test('non-native passive overlay stays click-through after subsequent visibility
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
});
|
||||
|
||||
test('non-native shaped input region stays mouse-enabled without focusing the overlay', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => true,
|
||||
};
|
||||
|
||||
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,
|
||||
overlayInteractionActive: false,
|
||||
nonNativeInputRegionActive: true,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
|
||||
});
|
||||
|
||||
test('suspended visible overlay hides without refreshing bounds or z-order', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
@@ -306,7 +353,7 @@ test('suspended visible overlay hides without refreshing bounds or z-order', ()
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('untracked non-macOS overlay shows passively when no tracker exists', () => {
|
||||
test('untracked Linux overlay stays hidden when no tracker exists', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
|
||||
@@ -341,7 +388,8 @@ test('untracked non-macOS overlay shows passively when no tracker exists', () =>
|
||||
} as never);
|
||||
|
||||
assert.equal(trackerWarning, false);
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(!calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
assert.ok(!calls.includes('osd'));
|
||||
@@ -384,6 +432,184 @@ test('passive Linux visible overlay does not take keyboard focus', () => {
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('passive Linux tracked overlay releases global topmost when mpv loses focus', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
calls.length = 0;
|
||||
|
||||
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.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('always-on-top:false'));
|
||||
assert.ok(calls.includes('fullscreen:false'));
|
||||
assert.ok(calls.includes('all-workspaces:false:plain'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
assert.ok(!calls.includes('enforce-order'));
|
||||
});
|
||||
|
||||
test('passive Linux fullscreen override overlay hides when mpv loses focus', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
calls.length = 0;
|
||||
|
||||
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');
|
||||
},
|
||||
hideNonNativeOverlayWhenTargetUnfocused: true,
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('always-on-top:false'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
assert.ok(!calls.includes('enforce-order'));
|
||||
});
|
||||
|
||||
test('Linux active overlay interaction does not focus the overlay over fullscreen mpv', () => {
|
||||
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => true,
|
||||
};
|
||||
|
||||
window.show();
|
||||
setFocused(false);
|
||||
calls.length = 0;
|
||||
|
||||
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,
|
||||
overlayInteractionActive: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('Linux active hover keeps global topmost when mpv loses focus and overlay is not focused', () => {
|
||||
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
setFocused(false);
|
||||
calls.length = 0;
|
||||
|
||||
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,
|
||||
overlayInteractionActive: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('all-workspaces:false:plain'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
@@ -604,6 +830,51 @@ test('Windows visible overlay waits for content-ready before first reveal', () =
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
});
|
||||
|
||||
test('Linux visible overlay waits for content-ready before first reveal', () => {
|
||||
const { window, calls, setContentReady } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
};
|
||||
setContentReady(false);
|
||||
|
||||
const run = () =>
|
||||
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);
|
||||
|
||||
run();
|
||||
|
||||
assert.ok(!calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
|
||||
setContentReady(true);
|
||||
run();
|
||||
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
});
|
||||
|
||||
test('tracked Windows overlay refresh rebinds while already visible', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
|
||||
@@ -18,6 +18,10 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
|
||||
|
||||
function releaseOverlayWindowLevel(window: BrowserWindow): void {
|
||||
window.setAlwaysOnTop(false);
|
||||
const fullscreenWindow = window as BrowserWindow & {
|
||||
setFullScreen?: (fullscreen: boolean) => void;
|
||||
};
|
||||
fullscreenWindow.setFullScreen?.(false);
|
||||
const allWorkspacesWindow = window as BrowserWindow & {
|
||||
setVisibleOnAllWorkspaces?: (
|
||||
visible: boolean,
|
||||
@@ -64,6 +68,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
visibleOverlayVisible: boolean;
|
||||
modalActive?: boolean;
|
||||
forceMousePassthrough?: boolean;
|
||||
nonNativeInputRegionActive?: boolean;
|
||||
suspendVisibleOverlay?: boolean;
|
||||
overlayInteractionActive?: boolean;
|
||||
mainWindow: BrowserWindow | null;
|
||||
@@ -87,6 +92,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
markOverlayLoadingOsdShown?: () => void;
|
||||
resetOverlayLoadingOsdSuppression?: () => void;
|
||||
resolveFallbackBounds?: () => WindowGeometry;
|
||||
hideNonNativeOverlayWhenTargetUnfocused?: boolean;
|
||||
}): void {
|
||||
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
|
||||
return;
|
||||
@@ -120,9 +126,9 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const showPassiveVisibleOverlay = (): boolean => {
|
||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||
const wasVisible = mainWindow.isVisible();
|
||||
const isVisibleOverlayFocused =
|
||||
overlayInteractionActive ||
|
||||
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
|
||||
const isVisibleOverlayWindowFocused =
|
||||
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
|
||||
const isVisibleOverlayFocused = overlayInteractionActive || isVisibleOverlayWindowFocused;
|
||||
const windowTracker = args.windowTracker;
|
||||
const canReportMacOSTargetMinimized =
|
||||
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
|
||||
@@ -181,12 +187,23 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
!isTrackedWindowsTargetMinimized &&
|
||||
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
|
||||
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
|
||||
const isNonNativePassiveOverlay =
|
||||
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
||||
const isNonNativeOverlay = !args.isWindowsPlatform && !args.isMacOSPlatform;
|
||||
const isNonNativePassiveOverlay = isNonNativeOverlay && !overlayInteractionActive;
|
||||
const hasNonNativeInputRegion =
|
||||
isNonNativePassiveOverlay && args.nonNativeInputRegionActive === true;
|
||||
const isTrackedNonNativeTargetFocused =
|
||||
!args.isWindowsPlatform && !args.isMacOSPlatform && !!args.windowTracker
|
||||
? (args.windowTracker.isTargetWindowFocused?.() ?? true)
|
||||
: true;
|
||||
const shouldReleaseNonNativeOverlayLevel =
|
||||
isNonNativeOverlay &&
|
||||
!!args.windowTracker &&
|
||||
!isVisibleOverlayFocused &&
|
||||
!isTrackedNonNativeTargetFocused;
|
||||
const shouldIgnoreMouseEvents =
|
||||
shouldUseMacOSMousePassthrough ||
|
||||
forceMousePassthrough ||
|
||||
isNonNativePassiveOverlay ||
|
||||
(isNonNativePassiveOverlay && !hasNonNativeInputRegion) ||
|
||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||
@@ -214,6 +231,11 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
// On Windows, z-order is enforced by the OS via the owner window mechanism
|
||||
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
|
||||
// without any manual z-order management.
|
||||
} else if (shouldReleaseNonNativeOverlayLevel) {
|
||||
releaseOverlayWindowLevel(mainWindow);
|
||||
if (args.hideNonNativeOverlayWhenTargetUnfocused && wasVisible) {
|
||||
mainWindow.hide();
|
||||
}
|
||||
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
|
||||
args.ensureOverlayWindowLevel(mainWindow);
|
||||
} else {
|
||||
@@ -223,7 +245,6 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const hasWebContents =
|
||||
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
|
||||
if (
|
||||
args.isWindowsPlatform &&
|
||||
hasWebContents &&
|
||||
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
|
||||
) {
|
||||
@@ -238,7 +259,11 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
}
|
||||
mainWindow.showInactive();
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
if (hasNonNativeInputRegion) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
if (args.isWindowsPlatform) {
|
||||
scheduleWindowsOverlayReveal(
|
||||
mainWindow,
|
||||
@@ -277,16 +302,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
if (
|
||||
!args.isWindowsPlatform &&
|
||||
!args.isMacOSPlatform &&
|
||||
!forceMousePassthrough &&
|
||||
overlayInteractionActive
|
||||
) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
return !shouldReleaseMacOSOverlayLevel;
|
||||
return !shouldReleaseNonNativeOverlayLevel;
|
||||
};
|
||||
|
||||
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
|
||||
@@ -385,9 +401,9 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
return;
|
||||
}
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
showPassiveVisibleOverlay();
|
||||
args.enforceOverlayLayerOrder();
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
releaseOverlayWindowLevel(mainWindow);
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ test('overlay window config explicitly disables renderer sandbox for preload com
|
||||
|
||||
assert.equal(options.title, 'SubMiner Overlay');
|
||||
assert.equal(options.backgroundColor, '#00000000');
|
||||
assert.equal(options.paintWhenInitiallyHidden, true);
|
||||
assert.equal(options.webPreferences?.sandbox, false);
|
||||
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
||||
});
|
||||
@@ -41,6 +42,59 @@ test('Linux visible overlay window allows compositor resize for mpv-sized placem
|
||||
}
|
||||
});
|
||||
|
||||
test('Linux visible overlay window stays managed so native apps can cover it', () => {
|
||||
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.alwaysOnTop, false);
|
||||
assert.equal(visibleOptions.focusable, true);
|
||||
assert.equal(modalOptions.focusable, true);
|
||||
} finally {
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Linux fullscreen visible overlay window uses X11 override-redirect-friendly options', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
try {
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
linuxX11FullscreenOverlay: true,
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(visibleOptions.alwaysOnTop, true);
|
||||
assert.equal(visibleOptions.focusable, false);
|
||||
assert.equal(visibleOptions.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');
|
||||
|
||||
|
||||
@@ -69,23 +69,11 @@ export function handleOverlayWindowBlurred(options: {
|
||||
onVisibleOverlayBlur?: () => void;
|
||||
platform?: NodeJS.Platform;
|
||||
}): boolean {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform === 'win32' && options.kind === 'visible') {
|
||||
if (options.kind === 'visible') {
|
||||
options.onVisibleOverlayBlur?.();
|
||||
return false;
|
||||
}
|
||||
if (platform === 'darwin' && options.kind === 'visible') {
|
||||
options.onVisibleOverlayBlur?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
options.ensureOverlayWindowLevel();
|
||||
if (options.kind === 'visible' && options.windowVisible) {
|
||||
options.moveWindowTop();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -11,12 +11,18 @@ export function buildOverlayWindowOptions(
|
||||
kind: OverlayWindowKind,
|
||||
options: {
|
||||
isDev: boolean;
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
): BrowserWindowConstructorOptions {
|
||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
|
||||
const shouldAllowCompositorResize = process.platform === 'linux' && kind === 'visible';
|
||||
const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible';
|
||||
const isLinuxFullscreenOverlay =
|
||||
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
|
||||
const shouldStartAlwaysOnTop =
|
||||
!(process.platform === 'win32' && kind === 'visible') &&
|
||||
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
|
||||
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
|
||||
|
||||
return {
|
||||
show: false,
|
||||
@@ -26,13 +32,14 @@ export function buildOverlayWindowOptions(
|
||||
x: 0,
|
||||
y: 0,
|
||||
transparent: true,
|
||||
paintWhenInitiallyHidden: true,
|
||||
backgroundColor: '#00000000',
|
||||
frame: false,
|
||||
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||
skipTaskbar: true,
|
||||
resizable: shouldAllowCompositorResize,
|
||||
hasShadow: false,
|
||||
focusable: true,
|
||||
focusable: !isLinuxFullscreenOverlay,
|
||||
acceptFirstMouse: true,
|
||||
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
|
||||
webPreferences: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ensureOverlayWindowLevel } from './overlay-window';
|
||||
import {
|
||||
handleOverlayWindowBeforeInputEvent,
|
||||
handleOverlayWindowBlurred,
|
||||
@@ -166,6 +167,49 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred skips Linux visible overlay restacking after focus loss', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred notifies Linux visible overlay blur callback without restacking', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
onVisibleOverlayBlur: () => {
|
||||
calls.push('visible-blur');
|
||||
},
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, ['visible-blur']);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -189,25 +233,9 @@ test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback wi
|
||||
assert.deepEqual(calls, ['visible-blur']);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
||||
test('handleOverlayWindowBlurred preserves modal window stacking', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
assert.equal(
|
||||
handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-visible');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-visible');
|
||||
},
|
||||
platform: 'linux',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
handleOverlayWindowBlurred({
|
||||
kind: 'modal',
|
||||
@@ -223,5 +251,40 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking'
|
||||
true,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['ensure-visible', 'move-visible', 'ensure-modal']);
|
||||
assert.deepEqual(calls, ['ensure-modal']);
|
||||
});
|
||||
|
||||
test('ensureOverlayWindowLevel promotes Linux overlay above fullscreen mpv without changing workspaces', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
ensureOverlayWindowLevel({
|
||||
getTitle: () => 'SubMiner Overlay',
|
||||
moveTop: () => calls.push('move-top'),
|
||||
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
|
||||
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
|
||||
},
|
||||
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
|
||||
calls.push(
|
||||
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
|
||||
);
|
||||
},
|
||||
} as never);
|
||||
} finally {
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'always-on-top:true:screen-saver:1',
|
||||
'all-workspaces:true:fullscreen',
|
||||
'move-top',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -78,7 +78,9 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
||||
window.moveTop();
|
||||
return;
|
||||
}
|
||||
window.setAlwaysOnTop(true);
|
||||
// Linux/X11 overlays start managed and only assert topmost while mpv owns the overlay layer.
|
||||
// Focus loss releases this again so native Wayland apps can cover the overlay on KDE.
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() });
|
||||
window.moveTop();
|
||||
@@ -106,13 +108,16 @@ export function createOverlayWindow(
|
||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (kind: OverlayWindowKind) => void;
|
||||
onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
): BrowserWindow {
|
||||
const window = new ElectronBrowserWindow(buildOverlayWindowOptions(kind, options));
|
||||
window.setSkipTaskbar(true);
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG
|
||||
] = false;
|
||||
@@ -172,7 +177,7 @@ export function createOverlayWindow(
|
||||
window.hide();
|
||||
|
||||
window.on('closed', () => {
|
||||
options.onWindowClosed(kind);
|
||||
options.onWindowClosed(kind, window);
|
||||
});
|
||||
|
||||
window.on('blur', () => {
|
||||
@@ -192,6 +197,11 @@ export function createOverlayWindow(
|
||||
});
|
||||
});
|
||||
|
||||
window.on('focus', () => {
|
||||
if (window.isDestroyed() || kind !== 'visible') return;
|
||||
options.onVisibleWindowFocused?.();
|
||||
});
|
||||
|
||||
if (options.isDev && kind === 'visible') {
|
||||
window.webContents.openDevTools({ mode: 'detach' });
|
||||
}
|
||||
|
||||
@@ -516,3 +516,28 @@ test('buildPluginSessionBindingsArtifact emits CLI args for plugin-bound session
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-count multi-line actions', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({
|
||||
copySubtitleMultiple: 'Ctrl+Shift+C',
|
||||
mineSentenceMultiple: 'Ctrl+Shift+S',
|
||||
}),
|
||||
keybindings: [],
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings: result.bindings,
|
||||
warnings: result.warnings,
|
||||
numericSelectionTimeoutMs: 2500,
|
||||
});
|
||||
const byActionId = new Map(
|
||||
artifact.bindings.flatMap((binding) =>
|
||||
binding.actionType === 'session-action' ? [[binding.actionId, binding]] : [],
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(byActionId.get('copySubtitleMultiple')?.cliArgs, undefined);
|
||||
assert.equal(byActionId.get('mineSentenceMultiple')?.cliArgs, undefined);
|
||||
});
|
||||
|
||||
@@ -358,6 +358,13 @@ function toPluginSessionBinding(binding: CompiledSessionBinding): PluginSessionB
|
||||
return binding;
|
||||
}
|
||||
|
||||
if (
|
||||
(binding.actionId === 'copySubtitleMultiple' || binding.actionId === 'mineSentenceMultiple') &&
|
||||
binding.payload?.count === undefined
|
||||
) {
|
||||
return binding;
|
||||
}
|
||||
|
||||
return { ...binding, cliArgs: buildSessionActionCliArgs(binding) };
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ test('subtitle processing falls back to plain subtitle when tokenization returns
|
||||
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
|
||||
});
|
||||
|
||||
test('subtitle processing can refresh current subtitle without text change', async () => {
|
||||
test('subtitle processing ignores duplicate current subtitle refresh without cache invalidation', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let tokenizeCalls = 0;
|
||||
const controller = createSubtitleProcessingController({
|
||||
@@ -119,10 +119,57 @@ test('subtitle processing can refresh current subtitle without text change', asy
|
||||
controller.refreshCurrentSubtitle();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(tokenizeCalls, 1);
|
||||
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
|
||||
});
|
||||
|
||||
test('subtitle processing coalesces refresh requests while current subtitle is processing', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let tokenizeCalls = 0;
|
||||
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
tokenizeCalls += 1;
|
||||
return await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolveTokenization = () => resolve({ text, tokens: [] });
|
||||
});
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('same');
|
||||
controller.refreshCurrentSubtitle();
|
||||
controller.refreshCurrentSubtitle('same');
|
||||
assert.ok(resolveTokenization);
|
||||
resolveTokenization({ text: 'same', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(tokenizeCalls, 1);
|
||||
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
|
||||
});
|
||||
|
||||
test('subtitle processing refresh re-tokenizes after cache invalidation', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let tokenizeCalls = 0;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
tokenizeCalls += 1;
|
||||
return { text, tokens: [{ value: tokenizeCalls } as never] };
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('same');
|
||||
await flushMicrotasks();
|
||||
controller.invalidateTokenizationCache();
|
||||
controller.refreshCurrentSubtitle();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(tokenizeCalls, 2);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'same', tokens: [] },
|
||||
{ text: 'same', tokens: [] },
|
||||
{ text: 'same', tokens: [{ value: 1 } as never] },
|
||||
{ text: 'same', tokens: [{ value: 2 } as never] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,9 +27,10 @@ export function createSubtitleProcessingController(
|
||||
const SUBTITLE_TOKENIZATION_CACHE_LIMIT = 256;
|
||||
let latestText = '';
|
||||
let lastEmittedText = '';
|
||||
let cacheGeneration = 0;
|
||||
let lastEmittedGeneration = 0;
|
||||
let processing = false;
|
||||
let staleDropCount = 0;
|
||||
let refreshRequested = false;
|
||||
const tokenizationCache = new Map<string, SubtitleData>();
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
|
||||
@@ -65,19 +66,19 @@ export function createSubtitleProcessingController(
|
||||
void (async () => {
|
||||
while (true) {
|
||||
const text = latestText;
|
||||
const forceRefresh = refreshRequested;
|
||||
refreshRequested = false;
|
||||
const generation = cacheGeneration;
|
||||
const startedAtMs = now();
|
||||
|
||||
if (!text.trim()) {
|
||||
deps.emitSubtitle({ text, tokens: null });
|
||||
lastEmittedText = text;
|
||||
lastEmittedGeneration = generation;
|
||||
break;
|
||||
}
|
||||
|
||||
let output: SubtitleData = { text, tokens: null };
|
||||
try {
|
||||
const cachedTokenized = forceRefresh ? null : getCachedTokenization(text);
|
||||
const cachedTokenized = getCachedTokenization(text);
|
||||
if (cachedTokenized) {
|
||||
output = cachedTokenized;
|
||||
} else {
|
||||
@@ -99,8 +100,16 @@ export function createSubtitleProcessingController(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (generation !== cacheGeneration) {
|
||||
deps.logDebug?.(
|
||||
`Dropped stale subtitle tokenization result after cache invalidation; elapsed=${now() - startedAtMs}ms`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
deps.emitSubtitle(output);
|
||||
lastEmittedText = text;
|
||||
lastEmittedGeneration = generation;
|
||||
deps.logDebug?.(
|
||||
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
|
||||
);
|
||||
@@ -112,7 +121,10 @@ export function createSubtitleProcessingController(
|
||||
})
|
||||
.finally(() => {
|
||||
processing = false;
|
||||
if (refreshRequested || latestText !== lastEmittedText) {
|
||||
if (
|
||||
latestText !== lastEmittedText ||
|
||||
(latestText.trim() && cacheGeneration !== lastEmittedGeneration)
|
||||
) {
|
||||
processLatest();
|
||||
}
|
||||
});
|
||||
@@ -133,11 +145,17 @@ export function createSubtitleProcessingController(
|
||||
if (!latestText.trim()) {
|
||||
return;
|
||||
}
|
||||
refreshRequested = true;
|
||||
if (
|
||||
processing ||
|
||||
(latestText === lastEmittedText && cacheGeneration === lastEmittedGeneration)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
processLatest();
|
||||
},
|
||||
invalidateTokenizationCache: () => {
|
||||
tokenizationCache.clear();
|
||||
cacheGeneration += 1;
|
||||
},
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => {
|
||||
setCachedTokenization(text, data);
|
||||
@@ -150,7 +168,7 @@ export function createSubtitleProcessingController(
|
||||
|
||||
latestText = text;
|
||||
lastEmittedText = text;
|
||||
refreshRequested = false;
|
||||
lastEmittedGeneration = cacheGeneration;
|
||||
return cached;
|
||||
},
|
||||
hasCachedSubtitle: (text: string) => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { shouldForceX11ElectronBackend } from './electron-backend';
|
||||
|
||||
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform });
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (original) Object.defineProperty(process, 'platform', original);
|
||||
}
|
||||
}
|
||||
|
||||
test('shouldForceX11ElectronBackend forces X11 on Linux except Hyprland/Sway', () => {
|
||||
withPlatform('linux', () => {
|
||||
assert.equal(shouldForceX11ElectronBackend({ XDG_CURRENT_DESKTOP: 'KDE' }), true);
|
||||
assert.equal(shouldForceX11ElectronBackend({ WAYLAND_DISPLAY: 'wayland-0' }), true);
|
||||
// Even an explicit Wayland hint is overridden to x11 on unsupported compositors.
|
||||
assert.equal(shouldForceX11ElectronBackend({ ELECTRON_OZONE_PLATFORM_HINT: 'wayland' }), true);
|
||||
// Hyprland/Sway keep native Wayland (guard reports explicit wayland hints elsewhere).
|
||||
assert.equal(shouldForceX11ElectronBackend({ HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }), false);
|
||||
assert.equal(shouldForceX11ElectronBackend({ SWAYSOCK: '/tmp/sway.sock' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('shouldForceX11ElectronBackend is false off Linux', () => {
|
||||
withPlatform('darwin', () => {
|
||||
assert.equal(shouldForceX11ElectronBackend({ XDG_CURRENT_DESKTOP: 'KDE' }), false);
|
||||
});
|
||||
withPlatform('win32', () => {
|
||||
assert.equal(shouldForceX11ElectronBackend({}), false);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,38 @@
|
||||
import { CliArgs, shouldStartApp } from '../../cli/args';
|
||||
import { createLogger } from '../../logger';
|
||||
import { isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
|
||||
|
||||
const logger = createLogger('core:electron-backend');
|
||||
|
||||
function getElectronOzonePlatformHint(): string | null {
|
||||
const hint = process.env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
|
||||
function getElectronOzonePlatformHint(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
const hint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
|
||||
if (hint) return hint;
|
||||
const ozone = process.env.OZONE_PLATFORM?.trim().toLowerCase();
|
||||
const ozone = env.OZONE_PLATFORM?.trim().toLowerCase();
|
||||
if (ozone) return ozone;
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldPreferWaylandBackend(): boolean {
|
||||
return Boolean(process.env.HYPRLAND_INSTANCE_SIGNATURE || process.env.SWAYSOCK);
|
||||
/**
|
||||
* Should the Electron app be pinned to the X11/XWayland ozone backend? True on Linux
|
||||
* unless we're on a natively-supported Wayland compositor (Hyprland/Sway) or the user
|
||||
* explicitly opted into the (unsupported) Wayland backend — which is reported by
|
||||
* {@link enforceUnsupportedWaylandMode} instead.
|
||||
*
|
||||
* The overlay relies on `setAlwaysOnTop`/`moveTop` to stay above mpv; those are no-ops
|
||||
* under a native Wayland surface, so XWayland is required for parity with Win/macOS. An
|
||||
* explicit `ELECTRON_OZONE_PLATFORM_HINT=wayland` is still overridden to x11 here (the
|
||||
* Electron Wayland backend is unsupported); the Hyprland/Sway case is left untouched so
|
||||
* {@link enforceUnsupportedWaylandMode} can report it.
|
||||
*/
|
||||
export function shouldForceX11ElectronBackend(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (process.platform !== 'linux') return false;
|
||||
return !isSupportedWaylandCompositor(env);
|
||||
}
|
||||
|
||||
export function forceX11Backend(args: CliArgs): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
if (!shouldStartApp(args)) return;
|
||||
if (shouldPreferWaylandBackend()) return;
|
||||
|
||||
const hint = getElectronOzonePlatformHint();
|
||||
if (hint === 'x11') return;
|
||||
if (!shouldForceX11ElectronBackend()) return;
|
||||
if (getElectronOzonePlatformHint() === 'x11') return;
|
||||
|
||||
process.env.ELECTRON_OZONE_PLATFORM_HINT = 'x11';
|
||||
process.env.OZONE_PLATFORM = 'x11';
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export { generateDefaultConfigFile } from './config-gen';
|
||||
export { enforceUnsupportedWaylandMode, forceX11Backend } from './electron-backend';
|
||||
export {
|
||||
enforceUnsupportedWaylandMode,
|
||||
forceX11Backend,
|
||||
shouldForceX11ElectronBackend,
|
||||
} from './electron-backend';
|
||||
export { resolveKeybindings } from './keybindings';
|
||||
export { resolveConfiguredShortcuts } from './shortcut-config';
|
||||
export { showDesktopNotification } from './notification';
|
||||
|
||||
Reference in New Issue
Block a user