From a982debd2f47c482627d26de9525b6509d73bcb0 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 10 Aug 2026 22:14:57 -0700 Subject: [PATCH] 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 --- changes/macos-window-activation.md | 4 ++ src/main.ts | 54 +++++++++++++------ src/main/runtime/config-settings-runtime.ts | 2 + .../runtime/config-settings-window.test.ts | 13 ++++- src/main/runtime/config-settings-window.ts | 6 +++ src/main/runtime/macos-app-activation.test.ts | 40 ++++++++++++++ src/main/runtime/macos-app-activation.ts | 27 ++++++++++ 7 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 changes/macos-window-activation.md create mode 100644 src/main/runtime/macos-app-activation.test.ts create mode 100644 src/main/runtime/macos-app-activation.ts diff --git a/changes/macos-window-activation.md b/changes/macos-window-activation.md new file mode 100644 index 00000000..9cadd27d --- /dev/null +++ b/changes/macos-window-activation.md @@ -0,0 +1,4 @@ +type: fixed +area: macos + +- `subminer anime` (and `--settings` / `--sync`) now bring their window to the front on macOS. `show()`/`focus()` only reorder windows inside the app that is already active, so the window opened behind the terminal that launched it; SubMiner now activates itself when opening one. The anime browser also restores its Dock icon before showing rather than after, because the overlay's fullscreen transform leaves the app as an accessory process that macOS refuses to bring forward at all. diff --git a/src/main.ts b/src/main.ts index 5d2deccb..66d92239 100644 --- a/src/main.ts +++ b/src/main.ts @@ -54,6 +54,7 @@ import { } from './main/runtime/linux-visible-overlay-window-mode'; import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive'; import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus'; +import { activateMacOSApp } from './main/runtime/macos-app-activation'; import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff'; import { resolveFreshPlaybackPaused } from './main/runtime/playback-paused-state'; import { mergeAiConfig } from './ai/config'; @@ -2225,6 +2226,15 @@ async function getCurrentYomitanAnkiDeckNameForRuntime(): Promise { }); } +// Pulls the whole app forward on macOS so a freshly opened window lands in front of the +// terminal (or mpv) that triggered it instead of behind it. +const activateAppForForegroundWindow = (): void => { + activateMacOSApp({ + stealAppFocus: () => app.focus({ steal: true }), + warn: (message, details) => logger.warn(message, details), + }); +}; + const configSettingsRuntime = createConfigSettingsRuntime({ fields: configSettingsFields, getConfigPath: () => configService.getConfigPath(), @@ -2247,6 +2257,7 @@ const configSettingsRuntime = createConfigSettingsRuntime({ settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'), promoteSettingsWindowAboveOverlay: (window) => promoteSettingsWindowAboveOverlay(window as BrowserWindow), + activateApp: activateAppForForegroundWindow, openPath: (targetPath) => shell.openPath(targetPath), ipcMain, ipcChannels: IPC_CHANNELS.request, @@ -2267,6 +2278,7 @@ const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({ settingsHtmlPath: path.join(__dirname, 'syncui', 'index.html'), promoteSettingsWindowAboveOverlay: (window) => promoteSettingsWindowAboveOverlay(window as BrowserWindow), + activateApp: activateAppForForegroundWindow, onClosed: () => { // requestAppQuit (not app.quit) so the forced-exit fallback covers a // stalled quit; a standalone sync window is the app's only reason to live. @@ -3391,6 +3403,17 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({ registerAnimeBrowserIpcHandlers({ ipcMain, runtime: animeBrowserRuntime }); let animeBrowserDockIconRetained = false; +const releaseAnimeBrowserDockIcon = (): void => { + if (!animeBrowserDockIconRetained) return; + animeBrowserDockIconRetained = false; + releaseDockIcon({ + dock: app.dock, + shouldRehide: () => { + const mainWindow = overlayManager.getMainWindow(); + return Boolean(mainWindow && !mainWindow.isDestroyed()); + }, + }); +}; const openAnimeBrowserWindowBase = createOpenConfigSettingsWindowHandler({ getSettingsWindow: () => appState.animeBrowserWindow, setSettingsWindow: (window) => { @@ -3403,15 +3426,9 @@ const openAnimeBrowserWindowBase = createOpenConfigSettingsWindowHandler({ settingsHtmlPath: path.join(__dirname, 'animeui', 'index.html'), promoteSettingsWindowAboveOverlay: (window) => promoteSettingsWindowAboveOverlay(window as BrowserWindow), + activateApp: activateAppForForegroundWindow, onClosed: () => { - animeBrowserDockIconRetained = false; - releaseDockIcon({ - dock: app.dock, - shouldRehide: () => { - const mainWindow = overlayManager.getMainWindow(); - return Boolean(mainWindow && !mainWindow.isDestroyed()); - }, - }); + releaseAnimeBrowserDockIcon(); // The bridge holds the stream-proxy tokens mpv is playing through, so it // must outlive the window. Only tear it down when the window was the // app's whole reason to be running — and once a video has launched, the @@ -3424,15 +3441,20 @@ const openAnimeBrowserWindowBase = createOpenConfigSettingsWindowHandler({ log: (message) => logger.error(message), }); const openAnimeBrowserWindowHandler = (): boolean => { - const opened = openAnimeBrowserWindowBase(); - if (opened) { - if (!animeBrowserDockIconRetained) { - animeBrowserDockIconRetained = true; - retainDockIcon({ dock: app.dock }); - } - ensureTrayHandler(); + // Restore the Dock icon *before* showing the window: while the overlay's fullscreen + // transform has left the app as a macOS accessory process it cannot become frontmost at + // all, so activating first and un-hiding after would leave the window buried. + if (!animeBrowserDockIconRetained) { + animeBrowserDockIconRetained = true; + retainDockIcon({ dock: app.dock }); } - return opened; + const opened = openAnimeBrowserWindowBase(); + if (!opened) { + releaseAnimeBrowserDockIcon(); + return false; + } + ensureTrayHandler(); + return true; }; const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({ diff --git a/src/main/runtime/config-settings-runtime.ts b/src/main/runtime/config-settings-runtime.ts index c0e063f3..c97f7de6 100644 --- a/src/main/runtime/config-settings-runtime.ts +++ b/src/main/runtime/config-settings-runtime.ts @@ -59,6 +59,7 @@ export interface ConfigSettingsRuntimeDeps void; + activateApp?: () => void; openPath(path: string): Promise; defaultAnkiConnectUrl: string; createAnkiClient(url: string): ConfigSettingsAnkiClient; @@ -149,6 +150,7 @@ export function createConfigSettingsRuntime 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']); diff --git a/src/main/runtime/config-settings-window.ts b/src/main/runtime/config-settings-window.ts index b2406df2..151d2894 100644 --- a/src/main/runtime/config-settings-window.ts +++ b/src/main/runtime/config-settings-window.ts @@ -13,6 +13,9 @@ export interface OpenConfigSettingsWindowDeps 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 { 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); }; diff --git a/src/main/runtime/macos-app-activation.test.ts b/src/main/runtime/macos-app-activation.test.ts new file mode 100644 index 00000000..1aced51b --- /dev/null +++ b/src/main/runtime/macos-app-activation.test.ts @@ -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); +}); diff --git a/src/main/runtime/macos-app-activation.ts b/src/main/runtime/macos-app-activation.ts new file mode 100644 index 00000000..1acfca43 --- /dev/null +++ b/src/main/runtime/macos-app-activation.ts @@ -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; + } +}