fix: settings window z-order on Hyprland and Linux app detach (#85)

This commit is contained in:
2026-05-25 13:21:38 -07:00
committed by GitHub
parent f7abcedd75
commit 097b619d71
18 changed files with 274 additions and 27 deletions
+1
View File
@@ -28,6 +28,7 @@ export {
shouldAutoInitializeOverlayRuntimeFromConfig,
} from './startup';
export { destroyYomitanSettingsWindow, openYomitanSettingsWindow } from './yomitan-settings';
export { promoteSettingsWindowAboveOverlay } from './settings-window-z-order';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export {
addYomitanNoteViaSearch,
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
promoteSettingsWindowAboveOverlay,
shouldPromoteSettingsWindowAboveOverlay,
} from './settings-window-z-order';
test('settings window promotion only applies to Hyprland sessions', () => {
assert.equal(
shouldPromoteSettingsWindowAboveOverlay('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
true,
);
assert.equal(
shouldPromoteSettingsWindowAboveOverlay('linux', { WAYLAND_DISPLAY: 'wayland-1' }),
false,
);
assert.equal(
shouldPromoteSettingsWindowAboveOverlay('darwin', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
false,
);
});
test('promoteSettingsWindowAboveOverlay raises Hyprland settings windows above the overlay', () => {
const calls: string[] = [];
const promoted = promoteSettingsWindowAboveOverlay(
{
isDestroyed: () => false,
getTitle: () => 'SubMiner Settings',
setAlwaysOnTop: (flag: boolean) => calls.push(`always-on-top:${flag}`),
moveTop: () => calls.push('move-top'),
} as never,
{
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' },
ensureHyprlandWindowFloatingByTitle: ({ title }) => {
calls.push(`hyprland-top:${title}`);
return true;
},
},
);
assert.equal(promoted, true);
assert.deepEqual(calls, ['always-on-top:true', 'move-top', 'hyprland-top:SubMiner Settings']);
});
test('promoteSettingsWindowAboveOverlay skips destroyed windows', () => {
const calls: string[] = [];
const promoted = promoteSettingsWindowAboveOverlay(
{
isDestroyed: () => true,
getTitle: () => 'SubMiner Settings',
setAlwaysOnTop: () => calls.push('always-on-top'),
moveTop: () => calls.push('move-top'),
} as never,
{
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' },
},
);
assert.equal(promoted, false);
assert.deepEqual(calls, []);
});
@@ -0,0 +1,48 @@
import type { BrowserWindow } from 'electron';
import {
ensureHyprlandWindowFloatingByTitle,
shouldAttemptHyprlandWindowPlacement,
} from './hyprland-window-placement';
type SettingsWindowLevelController = Pick<
BrowserWindow,
'getTitle' | 'isDestroyed' | 'moveTop' | 'setAlwaysOnTop'
>;
type PromoteSettingsWindowOptions = {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
ensureHyprlandWindowFloatingByTitle?: typeof ensureHyprlandWindowFloatingByTitle;
};
export function shouldPromoteSettingsWindowAboveOverlay(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): boolean {
return shouldAttemptHyprlandWindowPlacement(platform, env);
}
export function promoteSettingsWindowAboveOverlay(
window: SettingsWindowLevelController,
options: PromoteSettingsWindowOptions = {},
): boolean {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
if (window.isDestroyed() || !shouldPromoteSettingsWindowAboveOverlay(platform, env)) {
return false;
}
window.setAlwaysOnTop(true);
window.moveTop();
const title = window.getTitle().trim();
if (title) {
(options.ensureHyprlandWindowFloatingByTitle ?? ensureHyprlandWindowFloatingByTitle)({
title,
platform,
env,
});
}
return true;
}
+24 -12
View File
@@ -106,20 +106,32 @@ test('yomitan settings URL disables the embedded popup preview', () => {
test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => {
const calls: string[] = [];
showYomitanSettingsWindow({
isDestroyed: () => false,
isMinimized: () => true,
restore: () => calls.push('restore'),
getSize: () => [1200, 800],
setSize: (width: number, height: number) => calls.push(`set-size:${width}x${height}`),
webContents: {
invalidate: () => calls.push('invalidate'),
showYomitanSettingsWindow(
{
isDestroyed: () => false,
isMinimized: () => true,
restore: () => calls.push('restore'),
getSize: () => [1200, 800],
setSize: (width: number, height: number) => calls.push(`set-size:${width}x${height}`),
webContents: {
invalidate: () => calls.push('invalidate'),
},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
} as never,
{
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
} as never);
);
assert.deepEqual(calls, ['restore', 'set-size:1200x800', 'invalidate', 'show', 'focus']);
assert.deepEqual(calls, [
'restore',
'set-size:1200x800',
'invalidate',
'show',
'focus',
'promote',
]);
});
test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => {
+9 -1
View File
@@ -1,6 +1,7 @@
import electron from 'electron';
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron';
import { createLogger } from '../../logger';
import { promoteSettingsWindowAboveOverlay } from './settings-window-z-order';
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron;
const logger = createLogger('main:yomitan-settings');
@@ -136,7 +137,12 @@ export function buildYomitanSettingsUrl(extensionId: string): string {
return `chrome-extension://${extensionId}/settings.html?popup-preview=false`;
}
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
export function showYomitanSettingsWindow(
settingsWindow: BrowserWindow,
options: {
promoteSettingsWindowAboveOverlay?: (settingsWindow: BrowserWindow) => void;
} = {},
): void {
if (settingsWindow.isDestroyed()) {
return;
}
@@ -148,6 +154,7 @@ export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
settingsWindow.webContents.invalidate();
settingsWindow.show();
settingsWindow.focus();
(options.promoteSettingsWindowAboveOverlay ?? promoteSettingsWindowAboveOverlay)(settingsWindow);
}
export function destroyYomitanSettingsWindow(settingsWindow: BrowserWindow | null): boolean {
@@ -177,6 +184,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
logger.info('Creating new settings window for extension:', options.yomitanExt.id);
const settingsWindow = new ElectronBrowserWindow({
title: 'Yomitan Settings',
width: 1200,
height: 800,
show: false,
+15 -2
View File
@@ -332,6 +332,7 @@ import {
mineSentenceCard as mineSentenceCardCore,
openYomitanSettingsWindow,
playNextSubtitleRuntime,
promoteSettingsWindowAboveOverlay,
registerGlobalShortcuts as registerGlobalShortcutsCore,
replayCurrentSubtitleRuntime,
resolveJellyfinPlaybackPlanRuntime,
@@ -565,6 +566,7 @@ import {
createCreateJellyfinSetupWindowHandler,
} from './main/runtime/setup-window-factory';
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
import {
isSameYoutubeMediaPath,
isYoutubeMediaPath,
@@ -2034,6 +2036,8 @@ const configSettingsRuntime = createConfigSettingsRuntime({
preloadPath: path.join(__dirname, 'preload-settings.js'),
}),
settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'),
promoteSettingsWindowAboveOverlay: (window) =>
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
openPath: (targetPath) => shell.openPath(targetPath),
ipcMain,
ipcChannels: IPC_CHANNELS.request,
@@ -4927,8 +4931,17 @@ const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
const buildEnsureOverlayWindowLevelMainDepsHandler =
createBuildEnsureOverlayWindowLevelMainDepsHandler({
shouldSuppressOverlayWindowLevel: (window) =>
appState.statsOverlayVisible && window === overlayManager.getMainWindow(),
shouldSuppressOverlayWindowLevel: (window) => {
const mainWindow = overlayManager.getMainWindow();
return (
(appState.statsOverlayVisible && window === mainWindow) ||
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
window,
mainWindow,
separateWindows: [appState.configSettingsWindow, appState.yomitanSettingsWindow],
})
);
},
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
afterEnsureOverlayWindowLevel: () => {
promoteStatsOverlayAbovePlayback();
@@ -56,6 +56,7 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
setSettingsWindow(window: TWindow | null): void;
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
openPath(path: string): Promise<string>;
defaultAnkiConnectUrl: string;
createAnkiClient(url: string): ConfigSettingsAnkiClient;
@@ -144,6 +145,7 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
setSettingsWindow: deps.setSettingsWindow,
createSettingsWindow: deps.createSettingsWindow,
settingsHtmlPath: deps.settingsHtmlPath,
promoteSettingsWindowAboveOverlay: deps.promoteSettingsWindowAboveOverlay,
log: deps.log,
});
@@ -6,6 +6,7 @@ test('createOpenConfigSettingsWindowHandler focuses existing settings window', (
const calls: string[] = [];
const existing = {
isDestroyed: () => false,
show: () => calls.push('show'),
focus: () => calls.push('focus'),
loadFile: () => calls.push('load'),
on: () => {},
@@ -18,10 +19,11 @@ test('createOpenConfigSettingsWindowHandler focuses existing settings window', (
throw new Error('Should not create a second window.');
},
settingsHtmlPath: '/tmp/settings.html',
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
});
assert.equal(open(), true);
assert.deepEqual(calls, ['focus']);
assert.deepEqual(calls, ['show', 'focus', 'promote']);
});
test('createOpenConfigSettingsWindowHandler creates window and clears closed state', () => {
@@ -29,6 +31,7 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
const handlers: { closed?: () => void } = {};
const created = {
isDestroyed: () => false,
show: () => calls.push('show'),
focus: () => calls.push('focus'),
loadFile: (path: string) => calls.push(`load:${path}`),
on: (event: string, handler: () => void) => {
@@ -41,10 +44,11 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
setSettingsWindow: (window) => calls.push(window ? 'set:window' : 'set:null'),
createSettingsWindow: () => created,
settingsHtmlPath: '/tmp/settings.html',
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
});
assert.equal(open(), true);
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'focus']);
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'show', 'focus', 'promote']);
assert.ok(handlers.closed);
handlers.closed();
assert.equal(calls.at(-1), 'set:null');
@@ -54,6 +58,7 @@ test('createOpenConfigSettingsWindowHandler clears failed load window state', as
const calls: string[] = [];
const created = {
isDestroyed: () => false,
show: () => calls.push('show'),
focus: () => calls.push('focus'),
loadFile: (path: string) => {
calls.push(`load:${path}`);
@@ -76,6 +81,7 @@ test('createOpenConfigSettingsWindowHandler clears failed load window state', as
assert.deepEqual(calls, [
'load:/tmp/missing-settings.html',
'set:window',
'show',
'focus',
'set:null',
'destroy',
+10 -2
View File
@@ -1,5 +1,6 @@
export interface ConfigSettingsWindowLike {
isDestroyed(): boolean;
show(): void;
focus(): void;
loadFile(path: string): unknown;
on(event: 'closed', handler: () => void): unknown;
@@ -11,6 +12,7 @@ export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWind
setSettingsWindow(window: TWindow | null): void;
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
log?: (message: string) => void;
}
@@ -18,9 +20,15 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
deps: OpenConfigSettingsWindowDeps<TWindow>,
): () => boolean {
return () => {
const showAndFocus = (window: TWindow): void => {
window.show();
window.focus();
deps.promoteSettingsWindowAboveOverlay?.(window);
};
const existing = deps.getSettingsWindow();
if (existing && !existing.isDestroyed()) {
existing.focus();
showAndFocus(existing);
return true;
}
@@ -35,7 +43,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
window.on('closed', () => {
deps.setSettingsWindow(null);
});
window.focus();
showAndFocus(window);
return true;
};
}
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order';
test('separate settings windows suppress visible overlay restacking', () => {
const mainWindow = { id: 'overlay', isDestroyed: () => false };
const settingsWindow = { id: 'settings', isDestroyed: () => false };
assert.equal(
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
window: mainWindow,
mainWindow,
separateWindows: [settingsWindow],
}),
true,
);
});
test('separate settings windows do not suppress unrelated or closed overlay work', () => {
const mainWindow = { id: 'overlay', isDestroyed: () => false };
const modalWindow = { id: 'modal', isDestroyed: () => false };
const closedSettingsWindow = { id: 'settings', isDestroyed: () => true };
assert.equal(
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
window: modalWindow,
mainWindow,
separateWindows: [{ isDestroyed: () => false }],
}),
false,
);
assert.equal(
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
window: mainWindow,
mainWindow,
separateWindows: [closedSettingsWindow, null],
}),
false,
);
});
@@ -0,0 +1,19 @@
type SeparateWindowLike = {
isDestroyed(): boolean;
};
function hasLiveSeparateWindow(windows: Array<SeparateWindowLike | null | undefined>): boolean {
return windows.some((window) => Boolean(window && !window.isDestroyed()));
}
export function shouldSuppressVisibleOverlayRaiseForSeparateWindow(options: {
window: unknown;
mainWindow: unknown;
separateWindows: Array<SeparateWindowLike | null | undefined>;
}): boolean {
if (!options.mainWindow || options.window !== options.mainWindow) {
return false;
}
return hasLiveSeparateWindow(options.separateWindows);
}
@@ -111,7 +111,7 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind
width: 1040,
height: 760,
title: 'SubMiner Settings',
show: true,
show: false,
autoHideMenuBar: true,
resizable: true,
backgroundColor: '#24273a',
+3 -1
View File
@@ -2,6 +2,7 @@ interface SetupWindowConfig {
width: number;
height: number;
title: string;
show?: boolean;
resizable?: boolean;
minimizable?: boolean;
maximizable?: boolean;
@@ -19,7 +20,7 @@ function createSetupWindowHandler<TWindow>(
width: config.width,
height: config.height,
title: config.title,
show: true,
show: config.show ?? true,
autoHideMenuBar: true,
...(config.resizable === undefined ? {} : { resizable: config.resizable }),
...(config.minimizable === undefined ? {} : { minimizable: config.minimizable }),
@@ -77,6 +78,7 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
width: 1040,
height: 760,
title: 'SubMiner Settings',
show: false,
resizable: true,
preloadPath: deps.preloadPath,
backgroundColor: '#24273a',