mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
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:
+38
-16
@@ -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';
|
||||
@@ -2216,6 +2217,15 @@ async function getCurrentYomitanAnkiDeckNameForRuntime(): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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(),
|
||||
@@ -2238,6 +2248,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,
|
||||
@@ -2258,6 +2269,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.
|
||||
@@ -3382,6 +3394,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) => {
|
||||
@@ -3394,15 +3417,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
|
||||
@@ -3415,15 +3432,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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user