fix: harden Windows single-instance startup (#498)
This commit is contained in:
committed by
GitHub
Unverified
parent
0cdafde2df
commit
04aa94f907
@@ -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 };
|
||||
|
||||
38
electron/main/main-window-focus.ts
Normal file
38
electron/main/main-window-focus.ts
Normal 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;
|
||||
}
|
||||
32
tests/unit/main-window-focus.test.ts
Normal file
32
tests/unit/main-window-focus.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user