fix: harden Windows single-instance startup (#498)

This commit is contained in:
cedric
2026-03-15 18:50:59 +08:00
committed by GitHub
Unverified
parent 0cdafde2df
commit 04aa94f907
3 changed files with 197 additions and 62 deletions

View File

@@ -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<void> {
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<void> {
);
// 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<void> {
});
}
// 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 };

View File

@@ -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;
}

View File

@@ -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);
});
});