fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101)

This commit is contained in:
2026-05-31 20:59:18 -07:00
committed by GitHub
parent b46b8dfa41
commit e1ea464bc9
103 changed files with 6314 additions and 353 deletions
+66
View File
@@ -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[] = [];
+48 -6
View File
@@ -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,
};
+273 -2
View File
@@ -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 = {
+37 -21
View File
@@ -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');
+1 -13
View File
@@ -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;
}
+10 -3
View File
@@ -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: {
+81 -18
View File
@@ -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',
]);
});
+13 -3
View File
@@ -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);
});
+7
View File
@@ -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) => {
+34
View File
@@ -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);
});
});
+21 -10
View File
@@ -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';
+5 -1
View File
@@ -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';