fix: close and recreate macOS windows safely

This commit is contained in:
鲨鱼辣椒
2026-09-29 19:50:25 +08:00
parent f443f3bc66
commit b5e6c663ef
19 changed files with 1079 additions and 172 deletions

View File

@@ -1,6 +1,7 @@
import type { BrowserWindow, Session, WebContents } from 'electron';
import { WebContentsView, session } from 'electron';
import type { AgentBrowserBounds } from '../../shared/agent-browser';
import { AgentBrowserFault } from './fault';
import type {
AgentBrowserAdapter,
AgentBrowserDebuggerPort,
@@ -88,11 +89,18 @@ function wrapWebContents(contents: WebContents, isVisible: () => boolean): Agent
export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
private readonly nativeViews = new WeakMap<AgentBrowserViewPort, WebContentsView>();
private readonly viewOwners = new WeakMap<AgentBrowserViewPort, BrowserWindow>();
private readonly guardedSessions = new WeakSet<Session>();
constructor(private readonly mainWindow: BrowserWindow) {}
constructor(private readonly mainWindow: BrowserWindow | (() => BrowserWindow | null)) {}
private currentWindow(): BrowserWindow | null {
const window = typeof this.mainWindow === 'function' ? this.mainWindow() : this.mainWindow;
return window && !window.isDestroyed() ? window : null;
}
createView(partition: string): AgentBrowserViewPort {
const owner = this.currentWindow();
const nativeView = new WebContentsView({
webPreferences: {
partition,
@@ -119,25 +127,29 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
const view: AgentBrowserViewPort = {
webContents: wrapWebContents(contents, () => nativeView.getVisible()),
setBounds: (bounds: AgentBrowserBounds) =>
nativeView.setBounds(this.toNativeBounds(bounds)),
nativeView.setBounds(this.toNativeBounds(bounds, owner)),
setVisible: (visible: boolean) => nativeView.setVisible(visible),
};
this.nativeViews.set(view, nativeView);
if (owner) this.viewOwners.set(view, owner);
return view;
}
mount(view: AgentBrowserViewPort): void {
const nativeView = this.requireNativeView(view);
if (!this.mainWindow.isDestroyed()) {
this.mainWindow.contentView.addChildView(nativeView);
const owner = this.viewOwners.get(view);
if (!owner || owner.isDestroyed()) {
throw new AgentBrowserFault('CLOSED', '请先打开麦洛窗口,再查看作品。', true);
}
owner.contentView.addChildView(nativeView);
}
unmount(view: AgentBrowserViewPort): void {
const nativeView = this.nativeViews.get(view);
if (!nativeView || this.mainWindow.isDestroyed()) return;
const owner = this.viewOwners.get(view);
if (!nativeView || !owner || owner.isDestroyed()) return;
try {
this.mainWindow.contentView.removeChildView(nativeView);
owner.contentView.removeChildView(nativeView);
} catch {
// Removing an already detached view is harmless during shutdown.
}
@@ -147,6 +159,7 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
const nativeView = this.nativeViews.get(view);
if (!nativeView) return;
this.nativeViews.delete(view);
this.viewOwners.delete(view);
if (!nativeView.webContents.isDestroyed()) {
nativeView.webContents.close({ waitForBeforeUnload: false });
}
@@ -201,10 +214,12 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
});
}
private toNativeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds {
const zoomFactor = this.mainWindow.webContents.getZoomFactor();
private toNativeBounds(bounds: AgentBrowserBounds, owner: BrowserWindow | null): AgentBrowserBounds {
// Detached release preflight views also work while there is no main window.
if (!owner || owner.isDestroyed()) return bounds;
const zoomFactor = owner.webContents.getZoomFactor();
const zoom = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
const contentBounds = this.mainWindow.getContentBounds();
const contentBounds = owner.getContentBounds();
const contentWidth = Math.max(0, Math.trunc(contentBounds.width));
const contentHeight = Math.max(0, Math.trunc(contentBounds.height));
const x = clamp(Math.round(bounds.x * zoom), 0, contentWidth);

View File

@@ -312,6 +312,7 @@ export class AgentBrowserModule {
}
open(input: AgentBrowserOpenInput): Promise<AgentBrowserSnapshot> {
const epoch = this.queueEpoch;
return this.serialize(async () => {
this.assertAvailable();
const projectPath = normalizeRequiredPath(input.projectPath);
@@ -322,6 +323,7 @@ export class AgentBrowserModule {
if (this.record && !samePath(this.record.projectPath, projectPath)) {
await this.closeInternal();
this.assertCommandEpoch(epoch);
}
if (this.record) {
const record = this.record;
@@ -335,6 +337,7 @@ export class AgentBrowserModule {
}
if (record.state !== 'attached') {
await this.closeInternal();
this.assertCommandEpoch(epoch);
}
}
if (this.record) {
@@ -346,6 +349,7 @@ export class AgentBrowserModule {
: Boolean(record.previewData);
if (shouldReplacePreview) {
await this.closeInternal(record);
this.assertCommandEpoch(epoch);
}
}
if (this.record) {
@@ -357,6 +361,7 @@ export class AgentBrowserModule {
try {
if (diagnosticOwner) {
await this.updateDiagnosticOwner(record, diagnosticOwner, true);
this.assertCommandEpoch(epoch);
}
if (bounds) {
this.applyPresentation(record, input.visible ?? true, bounds);
@@ -403,9 +408,14 @@ export class AgentBrowserModule {
this.registerListeners(record);
record.view.webContents.denyWindowOpen();
if (bounds) record.view.setBounds(bounds);
record.view.setVisible(record.visible);
this.adapter.mount(record.view);
try {
if (bounds) record.view.setBounds(bounds);
record.view.setVisible(record.visible);
this.adapter.mount(record.view);
} catch (error) {
await this.closeInternal(record);
throw error;
}
try {
await this.runWhileActive(
@@ -413,12 +423,15 @@ export class AgentBrowserModule {
record.view.webContents.loadURL(RENDERER_PRIME_URL),
OPEN_TIMEOUT_MS,
);
this.assertCommandEpoch(epoch);
await this.runWhileActive(
record,
this.attachDebugger(record, false),
OPEN_TIMEOUT_MS,
);
this.assertCommandEpoch(epoch);
if (injectProjectData) await this.installPreviewData(record, undefined, targetUrl);
this.assertCommandEpoch(epoch);
} catch (error) {
await this.closeInternal(record);
throw toFault(
@@ -432,6 +445,7 @@ export class AgentBrowserModule {
try {
await this.loadPageUntilReady(record, targetUrl);
this.assertCommandEpoch(epoch);
try {
record.view.webContents.navigationHistory.clear();
} catch {
@@ -658,8 +672,18 @@ export class AgentBrowserModule {
async close(projectPath?: string): Promise<AgentBrowserSnapshot> {
if (this.record && projectPath) this.assertProject(this.record, projectPath);
this.preemptCommands('开发浏览器已关闭。');
await this.closeInternal();
return this.closedSnapshot();
const previousBarrier = this.lifecycleBarrier;
let releaseBarrier!: () => void;
this.lifecycleBarrier = new Promise<void>((resolveBarrier) => {
releaseBarrier = resolveBarrier;
});
try {
await previousBarrier;
await this.closeInternal();
return this.closedSnapshot();
} finally {
releaseBarrier();
}
}
async resetProfile(projectPath: string): Promise<AgentBrowserSnapshot> {
@@ -1709,6 +1733,7 @@ export class AgentBrowserModule {
if (expected && this.record !== expected) return;
record.state = 'closing';
await this.invalidatePreviewData(record, 'preview_closed');
if (this.record !== record) return;
this.notifyLifecycle({
type: 'closed',
projectId: record.projectId,
@@ -1812,15 +1837,7 @@ export class AgentBrowserModule {
try {
await barrier;
if (callerSettled) return;
if (epoch !== this.queueEpoch) {
throw new AgentBrowserFault(
'CLOSED',
'开发浏览器操作已被新的生命周期取代。',
true,
this.record?.generation,
'unknown',
);
}
this.assertCommandEpoch(epoch);
const value = await operation();
if (!callerSettled) {
callerSettled = true;
@@ -1842,6 +1859,18 @@ export class AgentBrowserModule {
return caller;
}
private assertCommandEpoch(epoch: number): void {
if (epoch !== this.queueEpoch) {
throw new AgentBrowserFault(
'CLOSED',
'开发浏览器操作已被新的生命周期取代。',
true,
this.record?.generation,
'unknown',
);
}
}
private serializeWithTimeout<T>(
operation: () => Promise<T>,
timeoutMs: number,

View File

@@ -1,4 +1,4 @@
/**
/**
* Electron Main Process Entry
* Manages window creation, system tray, and IPC handlers
*/
@@ -18,6 +18,7 @@ import { logger } from '../utils/logger';
import { initTelemetry } from '../utils/telemetry';
import { isQuitting, setQuitting } from './app-state';
import { registerMainWindowClose } from './main-window-close';
import { applyProxySettings } from './proxy';
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
import {
@@ -204,6 +205,8 @@ let imageWorkspaceModule: DesignWorkspaceModule | null = null;
let backgroundLifecycle!: BackgroundLifecycleController;
let releaseJobs: ReleaseJobManager | null = null;
let codingProducts: CodingProductComposition | null = null;
let windowIpcBindings: ReturnType<typeof registerIpcHandlers> | null = null;
let unsubscribeAuthSession: (() => void) | null = null;
const mainWindowFocusState = createMainWindowFocusState();
const quitLifecycleState = createQuitLifecycleState();
const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv);
@@ -337,10 +340,21 @@ function focusMainWindow(): void {
focusWindow(mainWindow);
}
function ensureMainWindow(): BrowserWindow | null {
if (isQuitting()) return null;
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
// Startup creates the first window after storage is ready. Later activations
// recreate only the window, keeping the Host API and running tasks alive.
if (!codingProducts) return null;
return createMainWindow();
}
function requestMainWindowFocus(reason: string): void {
if (isQuitting()) return;
const window = ensureMainWindow();
const focusRequest = requestSecondInstanceFocus(
mainWindowFocusState,
Boolean(mainWindow && !mainWindow.isDestroyed()),
Boolean(window && !window.webContents.isLoadingMainFrame()),
);
if (focusRequest === 'focus-now') {
@@ -378,30 +392,34 @@ function registerMakeloreProtocolClient(): void {
if (!registered) logger.warn(`Failed to register ${NIANCODE_APP_PROTOCOL}:// protocol handler`);
}
function sendAuthSession(
session: ReturnType<typeof getWorksSquareSessionSnapshot>,
reason: WorksSquareSessionChangeReason = 'changed',
previousSession: ReturnType<typeof getWorksSquareSessionSnapshot> = null,
): void {
if (!session && getWorksSquareSessionRestoreStatus() === 'unavailable') return;
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('auth:session-changed', session);
}
if (!session && reason === 'terminal') {
void clearManagedWorksSquareRuntimeBestEffort({
codingProducts: codingProducts ?? undefined,
imageWorkspace: imageWorkspaceModule ?? undefined,
}, 'terminal session invalidation', previousSession?.accessToken);
}
}
function createMainWindow(): BrowserWindow {
const win = createWindow();
const sendAuthSession = (
session: ReturnType<typeof getWorksSquareSessionSnapshot>,
reason: WorksSquareSessionChangeReason = 'changed',
previousSession: ReturnType<typeof getWorksSquareSessionSnapshot> = null,
) => {
if (!session && getWorksSquareSessionRestoreStatus() === 'unavailable') return;
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send('auth:session-changed', session);
}
if (!session && reason === 'terminal') {
void clearManagedWorksSquareRuntimeBestEffort({
codingProducts: codingProducts ?? undefined,
imageWorkspace: imageWorkspaceModule ?? undefined,
}, 'terminal session invalidation', previousSession?.accessToken);
}
};
const unsubscribeAuthSession = subscribeWorksSquareSession(sendAuthSession);
mainWindow = win;
windowIpcBindings?.bindWindow(win);
appUpdater.setMainWindow(win);
win.webContents.on('did-finish-load', () => {
sendAuthSession(getWorksSquareSessionSnapshot());
if (mainWindow === win) sendAuthSession(getWorksSquareSessionSnapshot());
});
const closeAgentBrowserForHostRenderer = (reason: string): void => {
if (mainWindow !== win) return;
void agentBrowser?.close().catch((error) => {
logger.warn(`Failed to close Agent Browser after ${reason}:`, error);
});
@@ -432,14 +450,14 @@ function createMainWindow(): BrowserWindow {
win.show();
});
win.on('close', (event) => {
if (!isQuitting() && !isE2EMode) {
event.preventDefault();
win.hide();
}
registerMainWindowClose(win, {
platform: process.platform,
isQuitting,
hideOnClose: process.platform !== 'darwin' && !isE2EMode,
});
win.on('hide', () => {
if (mainWindow !== win) return;
backgroundLifecycle?.setActivity({
...backgroundLifecycle.getActivity(),
visible: false,
@@ -452,6 +470,7 @@ function createMainWindow(): BrowserWindow {
});
win.on('show', () => {
if (mainWindow !== win) return;
backgroundLifecycle?.setActivity({
...backgroundLifecycle.getActivity(),
visible: true,
@@ -459,18 +478,20 @@ function createMainWindow(): BrowserWindow {
});
win.on('closed', () => {
unsubscribeAuthSession();
const browser = agentBrowser;
agentBrowser = null;
void browser?.dispose().catch((error) => {
logger.warn('Failed to dispose Agent Browser after the main window closed:', error);
if (mainWindow !== win) return;
mainWindow = null;
appUpdater.setMainWindow(null);
backgroundLifecycle?.setActivity({
...backgroundLifecycle.getActivity(),
visible: false,
});
// The module belongs to the process, not this window. Disposing it here
// would leave the coding tools and Host API holding an unusable browser.
void agentBrowser?.close().catch((error) => {
logger.warn('Failed to close Agent Browser after the main window closed:', error);
});
if (mainWindow === win) {
mainWindow = null;
}
});
mainWindow = win;
return win;
}
@@ -509,23 +530,23 @@ async function initialize(): Promise<void> {
logger.info('AI painting workspace is using the Works Square V2 cloud contract');
// Set application menu
createMenu();
createMenu(ensureMainWindow);
// Create the main window
const window = createMainWindow();
agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window));
agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(() => mainWindow));
// Create system tray
if (!isE2EMode) {
createTray(window);
createTray(() => mainWindow, ensureMainWindow);
}
// Register IPC handlers
backgroundLifecycle = new BackgroundLifecycleController({
...(isE2EMode ? { idleStopMs: 250 } : {}),
onSleep: () => {
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.send('lifecycle:sleep');
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('lifecycle:sleep');
}
},
onStopRuntime: async () => await codingProducts?.sleep('background_sleep'),
@@ -544,8 +565,8 @@ async function initialize(): Promise<void> {
});
releaseJobs.on('status', (status) => {
hostEventBus.emit('release-job:status', status);
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.send('release-job:status', status);
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('release-job:status', status);
}
});
const codingAppPath = app.isPackaged
@@ -557,14 +578,14 @@ async function initialize(): Promise<void> {
browser: agentBrowser,
requestAgentBrowserPresentation: (snapshot) => {
hostEventBus.emit('agent-browser:show', snapshot);
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.send('agent-browser:show', snapshot);
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('agent-browser:show', snapshot);
}
},
publishAgentBrowserState: (snapshot) => {
hostEventBus.emit('agent-browser:state', snapshot);
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.send('agent-browser:state', snapshot);
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('agent-browser:state', snapshot);
}
},
getLocalProxyCredential: () => getHostApiToken() || undefined,
@@ -587,7 +608,7 @@ async function initialize(): Promise<void> {
const hostApiContext: HostApiContext = {
codingProjectStore,
eventBus: hostEventBus,
mainWindow: window,
get mainWindow() { return mainWindow; },
agentBrowser,
worksSubmissionBinding: worksSubmissionBinding ?? undefined,
imageWorkspace,
@@ -596,7 +617,8 @@ async function initialize(): Promise<void> {
codingProducts,
previewDataSession: codingProducts.previewDataSession,
};
registerIpcHandlers(window, backgroundLifecycle, hostApiContext);
windowIpcBindings = registerIpcHandlers(() => mainWindow, backgroundLifecycle, hostApiContext);
unsubscribeAuthSession = subscribeWorksSquareSession(sendAuthSession);
void initializeRememberedPassword({
secureStorage: useSecureWorksSquareSessionPersistence ? safeStorage : null,
@@ -754,16 +776,12 @@ if (gotTheLock) {
// Register activate handler AFTER app is ready to prevent
// "Cannot create BrowserWindow before app is ready" on macOS.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
} else {
focusMainWindow();
}
requestMainWindowFocus('app activation');
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin' || isE2EMode) {
if (process.platform !== 'darwin') {
app.quit();
}
});
@@ -783,6 +801,8 @@ if (gotTheLock) {
return;
}
unsubscribeAuthSession?.();
unsubscribeAuthSession = null;
hostEventBus.closeAll();
codingProducts?.previewDataSession?.invalidate('main_shutdown');
hostApiServer?.close();

View File

@@ -22,6 +22,11 @@ import type { BackgroundLifecycleController, DesktopActivity } from './backgroun
import { collectPerformanceSnapshot } from './performance-diagnostics';
import type { HostApiContext } from '../api/context';
import { takeCloudAgentRoute } from './app-deep-link';
import { resolveMainWindow, type MainWindowProvider } from './window-provider';
export interface WindowIpcBindings {
bindWindow(window: BrowserWindow): void;
}
type UnifiedRequest = {
id?: string;
@@ -236,24 +241,41 @@ async function handleUnifiedRequest(
}
export function registerIpcHandlers(
mainWindow: BrowserWindow,
mainWindow: MainWindowProvider,
lifecycle?: BackgroundLifecycleController,
hostApiContext?: HostApiContext,
): void {
registerHostApiProxyHandlers(hostApiContext);
registerTranscriptExportHandler(mainWindow);
registerConversationLinkContextMenuHandler(mainWindow);
const rendererLeases = new Set<string>();
const releaseRendererLeases = (): void => {
if (rendererLeases.size === 0) return;
for (const id of rendererLeases) lifecycle?.releaseLease(id);
rendererLeases.clear();
): WindowIpcBindings {
const getMainWindow = (): BrowserWindow | null => resolveMainWindow(mainWindow);
const getRequestWindow = (event: Electron.IpcMainInvokeEvent): BrowserWindow | null => (
(event?.sender && BrowserWindow.fromWebContents(event.sender)) || getMainWindow()
);
const requireRequestWindow = (event: Electron.IpcMainInvokeEvent): BrowserWindow => {
const window = getRequestWindow(event);
if (!window || window.isDestroyed()) throw new Error('Main window is unavailable');
return window;
};
const rendererWebContents = mainWindow.webContents;
if (rendererWebContents && typeof rendererWebContents.on === 'function') {
rendererWebContents.on('render-process-gone', releaseRendererLeases);
rendererWebContents.on('destroyed', releaseRendererLeases);
}
registerHostApiProxyHandlers(hostApiContext);
registerTranscriptExportHandler(getMainWindow);
registerConversationLinkContextMenuHandler(getMainWindow);
const leasesByRenderer = new WeakMap<Electron.WebContents, Set<string>>();
const bindRenderer = (contents: Electron.WebContents): Set<string> => {
const existing = leasesByRenderer.get(contents);
if (existing) return existing;
const leases = new Set<string>();
leasesByRenderer.set(contents, leases);
const releaseLeases = (): void => {
for (const id of leases) lifecycle?.releaseLease(id);
leases.clear();
};
contents.on('render-process-gone', releaseLeases);
contents.on('destroyed', releaseLeases);
return leases;
};
const bindWindow = (window: BrowserWindow): void => {
if (!window.isDestroyed() && !window.webContents.isDestroyed()) bindRenderer(window.webContents);
};
const initialWindow = getMainWindow();
if (initialWindow) bindWindow(initialWindow);
ipcMain.handle('app:request', async (_event, request: UnifiedRequest) => (
handleUnifiedRequest(request)
));
@@ -270,31 +292,40 @@ export function registerIpcHandlers(
});
ipcMain.handle('app:performance', () => collectPerformanceSnapshot(lifecycle));
ipcMain.handle('lifecycle:activity', (_event, activity: DesktopActivity) => {
ipcMain.handle('lifecycle:activity', (event, activity: DesktopActivity) => {
const window = getMainWindow();
if (event?.sender && event.sender !== window?.webContents) {
return lifecycle?.getActivity();
}
const nextActivity = activity && typeof activity === 'object'
? activity
: { visible: true, module: null };
lifecycle?.setActivity(nextActivity);
if ((!nextActivity.visible || nextActivity.module !== 'painting')
&& !mainWindow.isDestroyed()
&& !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('lifecycle:pause');
&& window
&& !window.webContents.isDestroyed()) {
window.webContents.send('lifecycle:pause');
}
return lifecycle?.getActivity() ?? nextActivity;
});
ipcMain.handle('lifecycle:lease', (_event, input: { id: string; kind: string; active: boolean }) => {
ipcMain.handle('lifecycle:lease', (event, input: { id: string; kind: string; active: boolean }) => {
if (!lifecycle) return { count: 0 };
const id = typeof input?.id === 'string' ? input.id.trim() : '';
if (!id) return { count: lifecycle.getLeaseCount() };
const contents = event?.sender ?? getMainWindow()?.webContents;
if (!contents || contents.isDestroyed()) return { count: lifecycle.getLeaseCount() };
const rendererLeases = bindRenderer(contents);
// A late destroyed event from an old renderer must not release a new one's lease.
const leaseId = `renderer:${contents.id}:${id}`;
if (input?.active) {
lifecycle.acquireLease({
id,
id: leaseId,
kind: typeof input.kind === 'string' && input.kind.trim() ? input.kind.trim() : 'unknown',
});
rendererLeases.add(id);
rendererLeases.add(leaseId);
} else {
lifecycle.releaseLease(id);
rendererLeases.delete(id);
lifecycle.releaseLease(leaseId);
rendererLeases.delete(leaseId);
}
return { count: lifecycle.getLeaseCount() };
});
@@ -330,19 +361,21 @@ export function registerIpcHandlers(
ipcMain.handle('shell:showItemInFolder', (_event, path: string) => shell.showItemInFolder(path));
ipcMain.handle('shell:openPath', (_event, path: string) => shell.openPath(path));
ipcMain.handle('dialog:open', (_event, options: Electron.OpenDialogOptions) => dialog.showOpenDialog(mainWindow, options));
ipcMain.handle('dialog:save', (_event, options: Electron.SaveDialogOptions) => dialog.showSaveDialog(mainWindow, options));
ipcMain.handle('dialog:message', (_event, options: Electron.MessageBoxOptions) => dialog.showMessageBox(mainWindow, options));
ipcMain.handle('dialog:open', (event, options: Electron.OpenDialogOptions) => dialog.showOpenDialog(requireRequestWindow(event), options));
ipcMain.handle('dialog:save', (event, options: Electron.SaveDialogOptions) => dialog.showSaveDialog(requireRequestWindow(event), options));
ipcMain.handle('dialog:message', (event, options: Electron.MessageBoxOptions) => dialog.showMessageBox(requireRequestWindow(event), options));
ipcMain.handle('window:minimize', () => mainWindow.minimize());
ipcMain.handle('window:maximize', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
ipcMain.handle('window:minimize', (event) => requireRequestWindow(event).minimize());
ipcMain.handle('window:maximize', (event) => {
const window = requireRequestWindow(event);
if (window.isMaximized()) {
window.unmaximize();
return false;
}
mainWindow.maximize();
window.maximize();
return true;
});
ipcMain.handle('window:close', () => mainWindow.close());
ipcMain.handle('window:isMaximized', () => mainWindow.isMaximized());
ipcMain.handle('window:close', (event) => requireRequestWindow(event).close());
ipcMain.handle('window:isMaximized', (event) => requireRequestWindow(event).isMaximized());
return { bindWindow };
}

View File

@@ -7,6 +7,7 @@ import {
shell,
type MenuItemConstructorOptions,
} from 'electron';
import { resolveMainWindow, type MainWindowProvider } from '../window-provider';
const MAX_LINK_LENGTH = 4_096;
const LOCAL_WEB_FILE_PATTERN = /\.(?:html?|xhtml)$/i;
@@ -117,7 +118,7 @@ export function createConversationLinkMenuTemplate(
throw new Error('Unsupported link context menu request');
}
export function registerConversationLinkContextMenuHandler(mainWindow: BrowserWindow): void {
export function registerConversationLinkContextMenuHandler(mainWindow: MainWindowProvider): void {
ipcMain.handle('shell:showLinkContextMenu', (event, request: ConversationLinkContextMenuRequest) => {
const template = createConversationLinkMenuTemplate(request, {
platform: process.platform,
@@ -128,7 +129,7 @@ export function registerConversationLinkContextMenuHandler(mainWindow: BrowserWi
writeText: (value) => clipboard.writeText(value),
});
const menu = Menu.buildFromTemplate(template);
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
menu.popup({ window: ownerWindow });
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? resolveMainWindow(mainWindow);
menu.popup(ownerWindow ? { window: ownerWindow } : {});
});
}

View File

@@ -1,5 +1,6 @@
import { writeFile } from 'node:fs/promises';
import { dialog, ipcMain, type BrowserWindow } from 'electron';
import { BrowserWindow, dialog, ipcMain } from 'electron';
import { resolveMainWindow, type MainWindowProvider } from '../window-provider';
interface TranscriptSaveRequest {
defaultPath?: unknown;
@@ -15,8 +16,8 @@ function safeDefaultPath(value: unknown): string {
return fileName.toLowerCase().endsWith('.md') ? fileName : `${fileName}.md`;
}
export function registerTranscriptExportHandler(mainWindow: BrowserWindow): void {
ipcMain.handle('transcript:save', async (_event, request: TranscriptSaveRequest) => {
export function registerTranscriptExportHandler(mainWindow: MainWindowProvider): void {
ipcMain.handle('transcript:save', async (event, request: TranscriptSaveRequest) => {
if (typeof request?.markdown !== 'string') {
throw new Error('Invalid transcript markdown');
}
@@ -26,10 +27,15 @@ export function registerTranscriptExportHandler(mainWindow: BrowserWindow): void
let selected: Electron.SaveDialogReturnValue;
try {
selected = await dialog.showSaveDialog(mainWindow, {
const owner = (event?.sender && BrowserWindow.fromWebContents(event.sender))
|| resolveMainWindow(mainWindow);
const options: Electron.SaveDialogOptions = {
defaultPath: safeDefaultPath(request.defaultPath),
filters: [{ name: 'Markdown', extensions: ['md'] }],
});
};
selected = owner
? await dialog.showSaveDialog(owner, options)
: await dialog.showSaveDialog(options);
} catch {
throw new Error('Failed to save transcript');
}

View File

@@ -0,0 +1,50 @@
import type { BrowserWindow } from 'electron';
/** Keep a fullscreen Space alive until macOS has finished leaving it. */
export function registerMainWindowClose(
window: BrowserWindow,
options: {
platform: NodeJS.Platform;
isQuitting: () => boolean;
hideOnClose: boolean;
},
): void {
let pendingClose = false;
let leavingFullscreen = false;
const cancelPendingClose = (): void => {
pendingClose = false;
window.removeListener('leave-full-screen', finishClose);
};
const finishClose = (): void => {
pendingClose = false;
if (!window.isDestroyed() && !options.isQuitting()) window.close();
};
window.on('leave-full-screen', () => { leavingFullscreen = false; });
window.on('close', (event) => {
if (options.isQuitting()) {
cancelPendingClose();
return;
}
if (pendingClose) {
event.preventDefault();
return;
}
if (options.platform === 'darwin' && window.isFullScreen()) {
event.preventDefault();
pendingClose = true;
window.once('leave-full-screen', finishClose);
if (!leavingFullscreen) {
leavingFullscreen = true;
window.setFullScreen(false);
}
return;
}
if (options.hideOnClose) {
event.preventDefault();
window.hide();
}
});
window.once('closed', cancelPendingClose);
}

View File

@@ -3,12 +3,18 @@
* Creates the native application menu for macOS/Windows/Linux
*/
import { Menu, app, BrowserWindow } from 'electron';
import { sendWhenWindowReady, showMainWindow, type EnsureMainWindow } from './window-provider';
/**
* Create application menu
*/
export function createMenu(): void {
export function createMenu(ensureMainWindow?: EnsureMainWindow): void {
const isMac = process.platform === 'darwin';
const navigate = (route: string): void => {
const window = ensureMainWindow ? ensureMainWindow() : BrowserWindow.getFocusedWindow();
showMainWindow(window);
sendWhenWindowReady(window, 'navigate', route);
};
const template: Electron.MenuItemConstructorOptions[] = [
// App menu (macOS only)
@@ -22,10 +28,7 @@ export function createMenu(): void {
{
label: 'Preferences...',
accelerator: 'Cmd+,',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/settings');
},
click: () => navigate('/settings'),
},
{ type: 'separator' as const },
{ role: 'services' as const },
@@ -47,10 +50,7 @@ export function createMenu(): void {
{
label: 'New Chat',
accelerator: 'CmdOrCtrl+N',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/');
},
click: () => navigate('/'),
},
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
@@ -104,42 +104,27 @@ export function createMenu(): void {
{
label: 'Dashboard',
accelerator: 'CmdOrCtrl+1',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/');
},
click: () => navigate('/'),
},
{
label: 'Sessions',
accelerator: 'CmdOrCtrl+2',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/sessions');
},
click: () => navigate('/sessions'),
},
{
label: 'Projects',
accelerator: 'CmdOrCtrl+3',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/projects');
},
click: () => navigate('/projects'),
},
{
label: 'Models',
accelerator: 'CmdOrCtrl+4',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/models');
},
click: () => navigate('/models'),
},
{
label: 'Settings',
accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/settings');
},
click: () => navigate('/settings'),
},
],
},

View File

@@ -2,10 +2,19 @@
* System Tray Management
* Creates and manages the system tray icon and menu
*/
import { Tray, Menu, BrowserWindow, app, nativeImage } from 'electron';
import { Tray, Menu, app, nativeImage } from 'electron';
import { join } from 'path';
import {
resolveMainWindow,
sendWhenWindowReady,
showMainWindow,
type EnsureMainWindow,
type MainWindowProvider,
} from './window-provider';
let tray: Tray | null = null;
let windowProvider: MainWindowProvider = () => null;
let ensureWindow: EnsureMainWindow | undefined;
/**
* Resolve the icons directory path (works in both dev and packaged mode)
@@ -20,7 +29,10 @@ function getIconsDir(): string {
/**
* Create system tray icon and menu
*/
export function createTray(mainWindow: BrowserWindow): Tray {
export function createTray(mainWindow: MainWindowProvider, ensureMainWindow?: EnsureMainWindow): Tray {
windowProvider = mainWindow;
ensureWindow = ensureMainWindow;
if (tray && !tray.isDestroyed()) return tray;
// Use platform-appropriate icon for system tray
const iconsDir = getIconsDir();
let iconPath: string;
@@ -60,9 +72,9 @@ export function createTray(mainWindow: BrowserWindow): Tray {
tray.setToolTip('Makelore - AI Assistant');
const showWindow = () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.focus();
const window = resolveMainWindow(windowProvider) ?? ensureWindow?.() ?? null;
showMainWindow(window);
return window;
};
// Create context menu
@@ -93,17 +105,13 @@ export function createTray(mainWindow: BrowserWindow): Tray {
{
label: 'Open Chat',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/');
sendWhenWindowReady(showWindow(), 'navigate', '/');
},
},
{
label: 'Open Settings',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/settings');
sendWhenWindowReady(showWindow(), 'navigate', '/settings');
},
},
],
@@ -114,8 +122,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
{
label: 'Check for Updates...',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.webContents.send('update:check');
sendWhenWindowReady(showWindow(), 'update:check');
},
},
{
@@ -133,21 +140,16 @@ export function createTray(mainWindow: BrowserWindow): Tray {
// Click to show window (Windows/Linux)
tray.on('click', () => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isVisible()) {
mainWindow.hide();
const window = resolveMainWindow(windowProvider);
if (window?.isVisible()) {
window.hide();
} else {
mainWindow.show();
mainWindow.focus();
showWindow();
}
});
// Double-click to show window (Windows)
tray.on('double-click', () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.focus();
});
tray.on('double-click', showWindow);
return tray;
}
@@ -168,5 +170,7 @@ export function destroyTray(): void {
if (tray) {
tray.destroy();
tray = null;
windowProvider = () => null;
ensureWindow = undefined;
}
}

View File

@@ -133,7 +133,7 @@ export class AppUpdater extends EventEmitter {
/**
* Set the main window for sending update events
*/
setMainWindow(window: BrowserWindow): void {
setMainWindow(window: BrowserWindow | null): void {
this.mainWindow = window;
}

View File

@@ -0,0 +1,39 @@
import type { BrowserWindow } from 'electron';
export type MainWindowProvider = BrowserWindow | (() => BrowserWindow | null);
export type EnsureMainWindow = () => BrowserWindow | null;
export function resolveMainWindow(provider: MainWindowProvider): BrowserWindow | null {
const window = typeof provider === 'function' ? provider() : provider;
return window && !window.isDestroyed?.() ? window : null;
}
export function showMainWindow(window: BrowserWindow | null): void {
if (!window || window.isDestroyed()) return;
if (window.isMinimized()) window.restore();
window.show();
window.focus();
}
/** A newly reopened renderer must finish loading before it can receive menu actions. */
export function sendWhenWindowReady(
window: BrowserWindow | null,
channel: string,
...args: unknown[]
): void {
if (!window || window.isDestroyed() || window.webContents.isDestroyed()) return;
const contents = window.webContents;
const send = (): void => {
contents.removeListener('destroyed', cancel);
if (!window.isDestroyed() && !contents.isDestroyed()) contents.send(channel, ...args);
};
const cancel = (): void => {
contents.removeListener('did-finish-load', send);
};
if (contents.isLoadingMainFrame() || !contents.getURL()) {
contents.once('did-finish-load', send);
contents.once('destroyed', cancel);
} else {
send();
}
}