eigent/electron/main/closeCoordinator.ts
Douglas Lai 8f4f575db9
feat: add native menus, shortcuts, and window chrome (#1858)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-21 20:39:55 +08:00

321 lines
10 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import type { BrowserWindow } from 'electron';
import {
WINDOW_CLOSE_REQUEST_CHANNEL,
type CloseIntent,
type WindowCloseRequest,
type WindowCloseResponse,
} from '../../src/shared/windowClose';
type CloseEvent = Parameters<Parameters<BrowserWindow['on']>[1]>[0] & {
preventDefault(): void;
};
type CoordinatedWindow = Pick<
BrowserWindow,
'close' | 'isDestroyed' | 'on' | 'off' | 'webContents'
>;
export interface CloseCoordinatorOptions {
defaultIntent: CloseIntent;
quit: () => void;
/** False while no renderer exists to answer a confirmation request. */
shouldGuard?: () => boolean;
/**
* How long to wait for the renderer to acknowledge the request before
* closing anyway. Once acknowledged, its visible dialog owns the pending
* decision. Without this initial watchdog, a renderer that never replies --
* crashed, hung, or unmounted by an error boundary -- leaves `pendingIntent`
* set forever, and because every later request short-circuits on it the
* window can never be closed and the app can only be force-quit.
*/
responseTimeoutMs?: number;
setTimeoutFn?: (handler: () => void, ms: number) => unknown;
clearTimeoutFn?: (handle: unknown) => void;
diagnostic?: (message: string) => void;
}
export const DEFAULT_CLOSE_RESPONSE_TIMEOUT_MS = 5_000;
/**
* Coordinates native Close Window and Quit requests with the renderer's
* running-task confirmation. App-level cleanup remains owned by the caller.
*/
export class CloseCoordinator {
private readonly defaultIntent: CloseIntent;
private readonly quit: () => void;
private readonly shouldGuard: () => boolean;
private readonly responseTimeoutMs: number;
private readonly setTimeoutFn: (handler: () => void, ms: number) => unknown;
private readonly clearTimeoutFn: (handle: unknown) => void;
private readonly diagnostic: (message: string) => void;
private responseTimer: unknown = null;
private window: CoordinatedWindow | null = null;
private pendingIntent: CloseIntent | null = null;
private pendingAcknowledged = false;
private nextIntent: CloseIntent | null = null;
private allowNextWindowClose = false;
private appQuitInProgress = false;
private rendererReady = false;
constructor({
defaultIntent,
quit,
shouldGuard = () => true,
responseTimeoutMs = DEFAULT_CLOSE_RESPONSE_TIMEOUT_MS,
setTimeoutFn = (handler, ms) => setTimeout(handler, ms),
clearTimeoutFn = (handle) => clearTimeout(handle as NodeJS.Timeout),
diagnostic = () => undefined,
}: CloseCoordinatorOptions) {
this.defaultIntent = defaultIntent;
this.quit = quit;
this.shouldGuard = shouldGuard;
this.responseTimeoutMs = responseTimeoutMs;
this.setTimeoutFn = setTimeoutFn;
this.clearTimeoutFn = clearTimeoutFn;
this.diagnostic = diagnostic;
}
private clearResponseTimer(): void {
if (this.responseTimer === null) return;
this.clearTimeoutFn(this.responseTimer);
this.responseTimer = null;
}
/**
* The user asked to close; a silent renderer must not veto that. Proceed as
* though they confirmed -- they saw no dialog either way.
*/
private readonly handleResponseTimeout = () => {
this.responseTimer = null;
const intent = this.pendingIntent;
if (!intent) return;
this.respond({ intent, action: 'confirm' });
};
private sendRequest(intent: CloseIntent): void {
const request: WindowCloseRequest = { intent };
if (
this.window &&
!this.window.isDestroyed() &&
!this.window.webContents.isDestroyed()
) {
this.window.webContents.send(WINDOW_CLOSE_REQUEST_CHANNEL, request);
}
this.clearResponseTimer();
if (this.responseTimeoutMs > 0) {
this.responseTimer = this.setTimeoutFn(
this.handleResponseTimeout,
this.responseTimeoutMs
);
}
}
private readonly handleWindowClose = (event: CloseEvent) => {
if (this.appQuitInProgress || this.allowNextWindowClose) {
this.allowNextWindowClose = false;
return;
}
if (
!this.shouldGuard() ||
!this.rendererReady ||
!this.window ||
this.window.webContents.isDestroyed()
) {
return;
}
event.preventDefault();
const intent = this.nextIntent ?? this.defaultIntent;
if (this.pendingIntent) return;
this.pendingIntent = intent;
this.pendingAcknowledged = false;
this.sendRequest(intent);
};
private readonly handleRendererUnresponsive = () => {
this.markRendererUnavailable('unresponsive');
};
private readonly handleRendererResponsive = () => {
this.markRendererReady('responsive');
};
private readonly handleRendererLoading = () => {
this.markRendererUnavailable('did-start-loading');
};
private readonly handleRendererGone = () => {
this.markRendererUnavailable('render-process-gone');
};
private readonly handleRendererDestroyed = () => {
this.markRendererUnavailable('destroyed');
};
private unbindHealthListeners(window: CoordinatedWindow): void {
window.off('unresponsive', this.handleRendererUnresponsive);
window.off('responsive', this.handleRendererResponsive);
window.webContents.off('did-start-loading', this.handleRendererLoading);
window.webContents.off('render-process-gone', this.handleRendererGone);
window.webContents.off('destroyed', this.handleRendererDestroyed);
}
bindWindow(window: CoordinatedWindow): void {
this.unbindWindow();
this.window = window;
this.clearResponseTimer();
this.pendingIntent = null;
this.pendingAcknowledged = false;
this.nextIntent = null;
this.allowNextWindowClose = false;
this.appQuitInProgress = false;
// `shouldGuard` owns the app-shell handshake. This flag tracks renderer
// health after binding and is cleared by loading/crash/unresponsive events.
this.rendererReady = true;
window.on('close', this.handleWindowClose);
window.on('unresponsive', this.handleRendererUnresponsive);
window.on('responsive', this.handleRendererResponsive);
window.webContents.on('did-start-loading', this.handleRendererLoading);
window.webContents.on('render-process-gone', this.handleRendererGone);
window.webContents.on('destroyed', this.handleRendererDestroyed);
}
unbindWindow(): void {
if (this.window) {
this.window.off('close', this.handleWindowClose);
this.unbindHealthListeners(this.window);
}
this.window = null;
this.clearResponseTimer();
this.pendingIntent = null;
this.pendingAcknowledged = false;
this.nextIntent = null;
this.allowNextWindowClose = false;
this.rendererReady = false;
}
markRendererReady(reason = 'app-shell-ready'): void {
this.rendererReady = true;
this.diagnostic(`[WINDOW CLOSE] Renderer ready (${reason})`);
}
markRendererUnavailable(reason: string): void {
const pendingIntent = this.pendingIntent;
const acknowledged = this.pendingAcknowledged;
this.rendererReady = false;
this.clearResponseTimer();
this.pendingIntent = null;
this.pendingAcknowledged = false;
this.nextIntent = null;
this.allowNextWindowClose = false;
this.diagnostic(
`[WINDOW CLOSE] Renderer unavailable (${reason}); cleared intent=${pendingIntent ?? 'none'} acknowledged=${acknowledged}`
);
}
request(intent: CloseIntent): void {
const window = this.window;
if (!window || window.isDestroyed()) {
if (intent === 'quit-app') {
this.appQuitInProgress = true;
this.quit();
}
return;
}
if (
(!this.shouldGuard() ||
!this.rendererReady ||
window.webContents.isDestroyed()) &&
intent === 'quit-app'
) {
this.appQuitInProgress = true;
this.quit();
return;
}
if (this.pendingIntent) {
// Quit is stronger than Close Window. If it arrives while the close
// confirmation is open (for example from the Dock), update that same
// dialog rather than letting a stale Close confirmation win.
if (intent === 'quit-app' && this.pendingIntent === 'close-window') {
this.pendingIntent = intent;
this.pendingAcknowledged = false;
this.sendRequest(intent);
}
return;
}
this.nextIntent = intent;
// The close event is the single gateway that publishes the request.
window.close();
this.nextIntent = null;
}
respond(response: WindowCloseResponse): boolean {
if (response.intent !== this.pendingIntent) return false;
this.clearResponseTimer();
if (response.action === 'acknowledge') {
// The renderer is alive and owns a visible decision UI. Keep the intent
// pending, but do not mistake normal user deliberation for a hung
// renderer and auto-confirm it through the watchdog.
this.pendingAcknowledged = true;
this.diagnostic(
`[WINDOW CLOSE] Renderer acknowledged intent=${response.intent}`
);
return true;
}
this.pendingIntent = null;
this.pendingAcknowledged = false;
if (response.action === 'cancel') return true;
if (response.intent === 'quit-app') {
this.appQuitInProgress = true;
this.quit();
return true;
}
const window = this.window;
if (!window || window.isDestroyed()) return false;
this.allowNextWindowClose = true;
window.close();
return true;
}
markAppQuitInProgress(): void {
this.appQuitInProgress = true;
this.clearResponseTimer();
this.pendingIntent = null;
this.pendingAcknowledged = false;
}
isAppQuitInProgress(): boolean {
return this.appQuitInProgress;
}
getPendingIntent(): CloseIntent | null {
return this.pendingIntent;
}
isRendererReady(): boolean {
return this.rendererReady;
}
}