fix(macos): bring app forward when opening settings/sync/anime windows

- add activateMacOSApp to steal app focus before window.focus(), since show()/focus() only reorder windows within an already-active app
- wire activateApp through config-settings-window/runtime into the settings, sync, and anime browser window handlers
- restore the anime browser's Dock icon before showing the window instead of after, since an accessory process cannot become frontmost
This commit is contained in:
2026-08-10 22:14:57 -07:00
parent 3ca7dcd664
commit 183560d2c3
7 changed files with 128 additions and 18 deletions
@@ -59,6 +59,7 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
activateApp?: () => void;
openPath(path: string): Promise<string>;
defaultAnkiConnectUrl: string;
createAnkiClient(url: string): ConfigSettingsAnkiClient;
@@ -149,6 +150,7 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
createSettingsWindow: deps.createSettingsWindow,
settingsHtmlPath: deps.settingsHtmlPath,
promoteSettingsWindowAboveOverlay: deps.promoteSettingsWindowAboveOverlay,
activateApp: deps.activateApp,
log: deps.log,
});
@@ -20,10 +20,11 @@ test('createOpenConfigSettingsWindowHandler focuses existing settings window', (
},
settingsHtmlPath: '/tmp/settings.html',
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
activateApp: () => calls.push('activate'),
});
assert.equal(open(), true);
assert.deepEqual(calls, ['show', 'focus', 'promote']);
assert.deepEqual(calls, ['show', 'activate', 'focus', 'promote']);
});
test('createOpenConfigSettingsWindowHandler creates window and clears closed state', () => {
@@ -45,11 +46,19 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
createSettingsWindow: () => created,
settingsHtmlPath: '/tmp/settings.html',
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
activateApp: () => calls.push('activate'),
onClosed: () => calls.push('on-closed'),
});
assert.equal(open(), true);
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'show', 'focus', 'promote']);
assert.deepEqual(calls, [
'load:/tmp/settings.html',
'set:window',
'show',
'activate',
'focus',
'promote',
]);
assert.ok(handlers.closed);
handlers.closed();
assert.deepEqual(calls.slice(-2), ['set:null', 'on-closed']);
@@ -13,6 +13,9 @@ export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWind
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
// macOS only: showing/focusing a window does not activate a background app, so the window
// opens behind whatever is frontmost. See activateMacOSApp.
activateApp?: () => void;
onClosed?: () => void;
log?: (message: string) => void;
}
@@ -23,6 +26,9 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
return () => {
const showAndFocus = (window: TWindow): void => {
window.show();
// Activate the app before focusing: the window can only become key once the app itself
// is frontmost.
deps.activateApp?.();
window.focus();
deps.promoteSettingsWindowAboveOverlay?.(window);
};
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { activateMacOSApp } from './macos-app-activation';
test('activateMacOSApp steals app focus on darwin', () => {
const calls: string[] = [];
const activated = activateMacOSApp({
platform: 'darwin',
stealAppFocus: () => calls.push('steal'),
});
assert.equal(activated, true);
assert.deepEqual(calls, ['steal']);
});
test('activateMacOSApp is a no-op off darwin', () => {
const calls: string[] = [];
for (const platform of ['linux', 'win32'] as NodeJS.Platform[]) {
assert.equal(activateMacOSApp({ platform, stealAppFocus: () => calls.push('steal') }), false);
}
assert.deepEqual(calls, []);
});
test('activateMacOSApp warns instead of throwing when activation fails', () => {
const warnings: string[] = [];
const activated = activateMacOSApp({
platform: 'darwin',
stealAppFocus: () => {
throw new Error('no window server');
},
warn: (message) => warnings.push(message),
});
assert.equal(activated, false);
assert.equal(warnings.length, 1);
});
+27
View File
@@ -0,0 +1,27 @@
export type MacOSAppActivationDeps = {
platform?: NodeJS.Platform;
stealAppFocus: () => void;
warn?: (message: string, details?: unknown) => void;
};
// macOS never promotes a background process to the foreground just because it opened a window:
// `BrowserWindow.show()`/`focus()` only reorder windows *within* the already-active app. Both ways
// `subminer anime` reaches the window (a cold launch from a terminal, or the command routed over
// the control socket to an instance that is already running behind mpv) leave another app
// frontmost, so the window appears buried. Activating the app itself is what pulls it forward.
//
// Callers must make sure the app is no longer a macOS accessory process first (see
// retainDockIcon) — an accessory app cannot become frontmost at all.
export function activateMacOSApp(deps: MacOSAppActivationDeps): boolean {
if ((deps.platform ?? process.platform) !== 'darwin') {
return false;
}
try {
deps.stealAppFocus();
return true;
} catch (error) {
deps.warn?.('Failed to activate app for foreground window', error);
return false;
}
}