mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -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:
@@ -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.
|
||||||
+38
-16
@@ -54,6 +54,7 @@ import {
|
|||||||
} from './main/runtime/linux-visible-overlay-window-mode';
|
} from './main/runtime/linux-visible-overlay-window-mode';
|
||||||
import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive';
|
import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive';
|
||||||
import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus';
|
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 { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff';
|
||||||
import { resolveFreshPlaybackPaused } from './main/runtime/playback-paused-state';
|
import { resolveFreshPlaybackPaused } from './main/runtime/playback-paused-state';
|
||||||
import { mergeAiConfig } from './ai/config';
|
import { mergeAiConfig } from './ai/config';
|
||||||
@@ -2225,6 +2226,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({
|
const configSettingsRuntime = createConfigSettingsRuntime({
|
||||||
fields: configSettingsFields,
|
fields: configSettingsFields,
|
||||||
getConfigPath: () => configService.getConfigPath(),
|
getConfigPath: () => configService.getConfigPath(),
|
||||||
@@ -2247,6 +2257,7 @@ const configSettingsRuntime = createConfigSettingsRuntime({
|
|||||||
settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'),
|
settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'),
|
||||||
promoteSettingsWindowAboveOverlay: (window) =>
|
promoteSettingsWindowAboveOverlay: (window) =>
|
||||||
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
||||||
|
activateApp: activateAppForForegroundWindow,
|
||||||
openPath: (targetPath) => shell.openPath(targetPath),
|
openPath: (targetPath) => shell.openPath(targetPath),
|
||||||
ipcMain,
|
ipcMain,
|
||||||
ipcChannels: IPC_CHANNELS.request,
|
ipcChannels: IPC_CHANNELS.request,
|
||||||
@@ -2267,6 +2278,7 @@ const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({
|
|||||||
settingsHtmlPath: path.join(__dirname, 'syncui', 'index.html'),
|
settingsHtmlPath: path.join(__dirname, 'syncui', 'index.html'),
|
||||||
promoteSettingsWindowAboveOverlay: (window) =>
|
promoteSettingsWindowAboveOverlay: (window) =>
|
||||||
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
||||||
|
activateApp: activateAppForForegroundWindow,
|
||||||
onClosed: () => {
|
onClosed: () => {
|
||||||
// requestAppQuit (not app.quit) so the forced-exit fallback covers a
|
// 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.
|
// 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 });
|
registerAnimeBrowserIpcHandlers({ ipcMain, runtime: animeBrowserRuntime });
|
||||||
|
|
||||||
let animeBrowserDockIconRetained = false;
|
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({
|
const openAnimeBrowserWindowBase = createOpenConfigSettingsWindowHandler({
|
||||||
getSettingsWindow: () => appState.animeBrowserWindow,
|
getSettingsWindow: () => appState.animeBrowserWindow,
|
||||||
setSettingsWindow: (window) => {
|
setSettingsWindow: (window) => {
|
||||||
@@ -3403,15 +3426,9 @@ const openAnimeBrowserWindowBase = createOpenConfigSettingsWindowHandler({
|
|||||||
settingsHtmlPath: path.join(__dirname, 'animeui', 'index.html'),
|
settingsHtmlPath: path.join(__dirname, 'animeui', 'index.html'),
|
||||||
promoteSettingsWindowAboveOverlay: (window) =>
|
promoteSettingsWindowAboveOverlay: (window) =>
|
||||||
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
||||||
|
activateApp: activateAppForForegroundWindow,
|
||||||
onClosed: () => {
|
onClosed: () => {
|
||||||
animeBrowserDockIconRetained = false;
|
releaseAnimeBrowserDockIcon();
|
||||||
releaseDockIcon({
|
|
||||||
dock: app.dock,
|
|
||||||
shouldRehide: () => {
|
|
||||||
const mainWindow = overlayManager.getMainWindow();
|
|
||||||
return Boolean(mainWindow && !mainWindow.isDestroyed());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// The bridge holds the stream-proxy tokens mpv is playing through, so it
|
// 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
|
// 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
|
// 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),
|
log: (message) => logger.error(message),
|
||||||
});
|
});
|
||||||
const openAnimeBrowserWindowHandler = (): boolean => {
|
const openAnimeBrowserWindowHandler = (): boolean => {
|
||||||
const opened = openAnimeBrowserWindowBase();
|
// Restore the Dock icon *before* showing the window: while the overlay's fullscreen
|
||||||
if (opened) {
|
// transform has left the app as a macOS accessory process it cannot become frontmost at
|
||||||
if (!animeBrowserDockIconRetained) {
|
// all, so activating first and un-hiding after would leave the window buried.
|
||||||
animeBrowserDockIconRetained = true;
|
if (!animeBrowserDockIconRetained) {
|
||||||
retainDockIcon({ dock: app.dock });
|
animeBrowserDockIconRetained = true;
|
||||||
}
|
retainDockIcon({ dock: app.dock });
|
||||||
ensureTrayHandler();
|
|
||||||
}
|
}
|
||||||
return opened;
|
const opened = openAnimeBrowserWindowBase();
|
||||||
|
if (!opened) {
|
||||||
|
releaseAnimeBrowserDockIcon();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ensureTrayHandler();
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
|
|||||||
createSettingsWindow(): TWindow;
|
createSettingsWindow(): TWindow;
|
||||||
settingsHtmlPath: string;
|
settingsHtmlPath: string;
|
||||||
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
|
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
|
||||||
|
activateApp?: () => void;
|
||||||
openPath(path: string): Promise<string>;
|
openPath(path: string): Promise<string>;
|
||||||
defaultAnkiConnectUrl: string;
|
defaultAnkiConnectUrl: string;
|
||||||
createAnkiClient(url: string): ConfigSettingsAnkiClient;
|
createAnkiClient(url: string): ConfigSettingsAnkiClient;
|
||||||
@@ -149,6 +150,7 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
|||||||
createSettingsWindow: deps.createSettingsWindow,
|
createSettingsWindow: deps.createSettingsWindow,
|
||||||
settingsHtmlPath: deps.settingsHtmlPath,
|
settingsHtmlPath: deps.settingsHtmlPath,
|
||||||
promoteSettingsWindowAboveOverlay: deps.promoteSettingsWindowAboveOverlay,
|
promoteSettingsWindowAboveOverlay: deps.promoteSettingsWindowAboveOverlay,
|
||||||
|
activateApp: deps.activateApp,
|
||||||
log: deps.log,
|
log: deps.log,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ test('createOpenConfigSettingsWindowHandler focuses existing settings window', (
|
|||||||
},
|
},
|
||||||
settingsHtmlPath: '/tmp/settings.html',
|
settingsHtmlPath: '/tmp/settings.html',
|
||||||
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
|
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
|
||||||
|
activateApp: () => calls.push('activate'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(open(), true);
|
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', () => {
|
test('createOpenConfigSettingsWindowHandler creates window and clears closed state', () => {
|
||||||
@@ -45,11 +46,19 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
|
|||||||
createSettingsWindow: () => created,
|
createSettingsWindow: () => created,
|
||||||
settingsHtmlPath: '/tmp/settings.html',
|
settingsHtmlPath: '/tmp/settings.html',
|
||||||
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
|
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
|
||||||
|
activateApp: () => calls.push('activate'),
|
||||||
onClosed: () => calls.push('on-closed'),
|
onClosed: () => calls.push('on-closed'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(open(), true);
|
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);
|
assert.ok(handlers.closed);
|
||||||
handlers.closed();
|
handlers.closed();
|
||||||
assert.deepEqual(calls.slice(-2), ['set:null', 'on-closed']);
|
assert.deepEqual(calls.slice(-2), ['set:null', 'on-closed']);
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWind
|
|||||||
createSettingsWindow(): TWindow;
|
createSettingsWindow(): TWindow;
|
||||||
settingsHtmlPath: string;
|
settingsHtmlPath: string;
|
||||||
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
|
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;
|
onClosed?: () => void;
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
}
|
}
|
||||||
@@ -23,6 +26,9 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
|
|||||||
return () => {
|
return () => {
|
||||||
const showAndFocus = (window: TWindow): void => {
|
const showAndFocus = (window: TWindow): void => {
|
||||||
window.show();
|
window.show();
|
||||||
|
// Activate the app before focusing: the window can only become key once the app itself
|
||||||
|
// is frontmost.
|
||||||
|
deps.activateApp?.();
|
||||||
window.focus();
|
window.focus();
|
||||||
deps.promoteSettingsWindowAboveOverlay?.(window);
|
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