fix: close and recreate macOS windows safely

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

No files matched your search

+73 -53
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();