From 04aa94f9073b0d072721427a56e2cc1914e68e45 Mon Sep 17 00:00:00 2001 From: cedric <31234075+xinzezhu@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:50:59 +0800 Subject: [PATCH] fix: harden Windows single-instance startup (#498) --- electron/main/index.ts | 189 ++++++++++++++++++--------- electron/main/main-window-focus.ts | 38 ++++++ tests/unit/main-window-focus.test.ts | 32 +++++ 3 files changed, 197 insertions(+), 62 deletions(-) create mode 100644 electron/main/main-window-focus.ts create mode 100644 tests/unit/main-window-focus.test.ts diff --git a/electron/main/index.ts b/electron/main/index.ts index 71d186143..67ef44f5b 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -21,6 +21,12 @@ import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToPro import { isQuitting, setQuitting } from './app-state'; import { applyProxySettings } from './proxy'; import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup'; +import { + clearPendingSecondInstanceFocus, + consumeMainWindowReady, + createMainWindowFocusState, + requestSecondInstanceFocus, +} from './main-window-focus'; import { getSetting } from '../utils/store'; import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config'; import { startHostApiServer } from '../api/server'; @@ -30,6 +36,8 @@ import { browserOAuthManager } from '../utils/browser-oauth'; import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync'; +const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop'; + // Disable GPU hardware acceleration globally for maximum stability across // all GPU configurations (no GPU, integrated, discrete). // @@ -58,17 +66,19 @@ if (process.platform === 'linux') { // Without this, two instances each spawn their own gateway process on the // same port, then each treats the other's gateway as "orphaned" and kills // it — creating an infinite kill/restart loop on Windows. +// The losing process must exit immediately so it never reaches Gateway startup. const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { - app.quit(); + app.exit(0); } // Global references let mainWindow: BrowserWindow | null = null; -const gatewayManager = new GatewayManager(); -const clawHubService = new ClawHubService(); -const hostEventBus = new HostEventBus(); +let gatewayManager!: GatewayManager; +let clawHubService!: ClawHubService; +let hostEventBus!: HostEventBus; let hostApiServer: Server | null = null; +const mainWindowFocusState = createMainWindowFocusState(); /** * Resolve the icons directory path (works in both dev and packaged mode) @@ -122,11 +132,6 @@ function createWindow(): BrowserWindow { show: false, }); - // Show window when ready to prevent visual flash - win.once('ready-to-show', () => { - win.show(); - }); - // Handle external links win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); @@ -144,6 +149,62 @@ function createWindow(): BrowserWindow { return win; } +function focusWindow(win: BrowserWindow): void { + if (win.isDestroyed()) { + return; + } + + if (win.isMinimized()) { + win.restore(); + } + + win.show(); + win.focus(); +} + +function focusMainWindow(): void { + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + + clearPendingSecondInstanceFocus(mainWindowFocusState); + focusWindow(mainWindow); +} + +function createMainWindow(): BrowserWindow { + const win = createWindow(); + + win.once('ready-to-show', () => { + if (mainWindow !== win) { + return; + } + + const action = consumeMainWindowReady(mainWindowFocusState); + if (action === 'focus') { + focusWindow(win); + return; + } + + win.show(); + }); + + win.on('close', (event) => { + if (!isQuitting()) { + event.preventDefault(); + win.hide(); + } + }); + + win.on('closed', () => { + if (mainWindow === win) { + mainWindow = null; + } + }); + + mainWindow = win; + return win; +} + /** * Initialize the application */ @@ -169,10 +230,10 @@ async function initialize(): Promise { createMenu(); // Create the main window - mainWindow = createWindow(); + const window = createMainWindow(); // Create system tray - createTray(mainWindow); + createTray(window); // Override security headers ONLY for the OpenClaw Gateway Control UI. // The URL filter ensures this callback only fires for gateway requests, @@ -198,33 +259,21 @@ async function initialize(): Promise { ); // Register IPC handlers - registerIpcHandlers(gatewayManager, clawHubService, mainWindow); + registerIpcHandlers(gatewayManager, clawHubService, window); hostApiServer = startHostApiServer({ gatewayManager, clawHubService, eventBus: hostEventBus, - mainWindow, + mainWindow: window, }); // Register update handlers - registerUpdateHandlers(appUpdater, mainWindow); + registerUpdateHandlers(appUpdater, window); // Note: Auto-check for updates is driven by the renderer (update store init) // so it respects the user's "Auto-check for updates" setting. - // Minimize to tray on close instead of quitting (macOS & Windows) - mainWindow.on('close', (event) => { - if (!isQuitting()) { - event.preventDefault(); - mainWindow?.hide(); - } - }); - - mainWindow.on('closed', () => { - mainWindow = null; - }); - // Repair any bootstrap files that only contain ClawX markers (no OpenClaw // template content). This fixes a race condition where ensureClawXContext() // previously created the file before the gateway could seed the full template. @@ -354,50 +403,66 @@ async function initialize(): Promise { }); } -// When a second instance is launched, focus the existing window instead. -app.on('second-instance', () => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); +if (gotTheLock) { + if (process.platform === 'win32') { + app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID); } -}); -// Application lifecycle -app.whenReady().then(() => { - void initialize().catch((error) => { - logger.error('Application initialization failed:', error); + gatewayManager = new GatewayManager(); + clawHubService = new ClawHubService(); + hostEventBus = new HostEventBus(); + + // When a second instance is launched, focus the existing window instead. + app.on('second-instance', () => { + logger.info('Second ClawX instance detected; redirecting to the existing window'); + + const focusRequest = requestSecondInstanceFocus( + mainWindowFocusState, + Boolean(mainWindow && !mainWindow.isDestroyed()), + ); + + if (focusRequest === 'focus-now') { + focusMainWindow(); + return; + } + + logger.debug('Main window is not ready yet; deferring second-instance focus until ready-to-show'); }); - // 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) { - mainWindow = createWindow(); - } else if (mainWindow && !mainWindow.isDestroyed()) { - // On macOS, clicking the dock icon should show the window if it's hidden - mainWindow.show(); - mainWindow.focus(); + // Application lifecycle + app.whenReady().then(() => { + void initialize().catch((error) => { + logger.error('Application initialization failed:', error); + }); + + // 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(); + } + }); + }); + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); } }); -}); -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app.on('before-quit', () => { - setQuitting(); - hostEventBus.closeAll(); - hostApiServer?.close(); - // Fire-and-forget: do not await gatewayManager.stop() here. - // Awaiting inside before-quit can stall Electron's quit sequence. - void gatewayManager.stop().catch((err) => { - logger.warn('gatewayManager.stop() error during quit:', err); + app.on('before-quit', () => { + setQuitting(); + hostEventBus.closeAll(); + hostApiServer?.close(); + // Fire-and-forget: do not await gatewayManager.stop() here. + // Awaiting inside before-quit can stall Electron's quit sequence. + void gatewayManager.stop().catch((err) => { + logger.warn('gatewayManager.stop() error during quit:', err); + }); }); -}); +} // Export for testing export { mainWindow, gatewayManager }; diff --git a/electron/main/main-window-focus.ts b/electron/main/main-window-focus.ts new file mode 100644 index 000000000..91afde3f4 --- /dev/null +++ b/electron/main/main-window-focus.ts @@ -0,0 +1,38 @@ +export interface MainWindowFocusState { + pendingSecondInstanceFocus: boolean; +} + +export type SecondInstanceFocusRequest = 'focus-now' | 'defer'; +export type MainWindowReadyAction = 'show' | 'focus'; + +export function createMainWindowFocusState(): MainWindowFocusState { + return { + pendingSecondInstanceFocus: false, + }; +} + +export function requestSecondInstanceFocus( + state: MainWindowFocusState, + hasFocusableMainWindow: boolean, +): SecondInstanceFocusRequest { + if (hasFocusableMainWindow) { + state.pendingSecondInstanceFocus = false; + return 'focus-now'; + } + + state.pendingSecondInstanceFocus = true; + return 'defer'; +} + +export function consumeMainWindowReady(state: MainWindowFocusState): MainWindowReadyAction { + if (state.pendingSecondInstanceFocus) { + state.pendingSecondInstanceFocus = false; + return 'focus'; + } + + return 'show'; +} + +export function clearPendingSecondInstanceFocus(state: MainWindowFocusState): void { + state.pendingSecondInstanceFocus = false; +} diff --git a/tests/unit/main-window-focus.test.ts b/tests/unit/main-window-focus.test.ts new file mode 100644 index 000000000..7f3d62b34 --- /dev/null +++ b/tests/unit/main-window-focus.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + consumeMainWindowReady, + createMainWindowFocusState, + requestSecondInstanceFocus, +} from '@electron/main/main-window-focus'; + +describe('main window focus coordination', () => { + it('defers second-instance focus until the main window is ready', () => { + const state = createMainWindowFocusState(); + + expect(requestSecondInstanceFocus(state, false)).toBe('defer'); + expect(state.pendingSecondInstanceFocus).toBe(true); + expect(consumeMainWindowReady(state)).toBe('focus'); + expect(state.pendingSecondInstanceFocus).toBe(false); + }); + + it('shows the main window normally when no second-instance focus is pending', () => { + const state = createMainWindowFocusState(); + + expect(consumeMainWindowReady(state)).toBe('show'); + expect(state.pendingSecondInstanceFocus).toBe(false); + }); + + it('focuses immediately when the main window already exists', () => { + const state = createMainWindowFocusState(); + requestSecondInstanceFocus(state, false); + + expect(requestSecondInstanceFocus(state, true)).toBe('focus-now'); + expect(state.pendingSecondInstanceFocus).toBe(false); + }); +});