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 { isQuitting, setQuitting } from './app-state';
|
||||||
import { applyProxySettings } from './proxy';
|
import { applyProxySettings } from './proxy';
|
||||||
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
|
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
|
||||||
|
import {
|
||||||
|
clearPendingSecondInstanceFocus,
|
||||||
|
consumeMainWindowReady,
|
||||||
|
createMainWindowFocusState,
|
||||||
|
requestSecondInstanceFocus,
|
||||||
|
} from './main-window-focus';
|
||||||
import { getSetting } from '../utils/store';
|
import { getSetting } from '../utils/store';
|
||||||
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config';
|
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config';
|
||||||
import { startHostApiServer } from '../api/server';
|
import { startHostApiServer } from '../api/server';
|
||||||
@@ -30,6 +36,8 @@ import { browserOAuthManager } from '../utils/browser-oauth';
|
|||||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||||
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
|
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
|
// Disable GPU hardware acceleration globally for maximum stability across
|
||||||
// all GPU configurations (no GPU, integrated, discrete).
|
// 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
|
// 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
|
// same port, then each treats the other's gateway as "orphaned" and kills
|
||||||
// it — creating an infinite kill/restart loop on Windows.
|
// 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();
|
const gotTheLock = app.requestSingleInstanceLock();
|
||||||
if (!gotTheLock) {
|
if (!gotTheLock) {
|
||||||
app.quit();
|
app.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global references
|
// Global references
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
const gatewayManager = new GatewayManager();
|
let gatewayManager!: GatewayManager;
|
||||||
const clawHubService = new ClawHubService();
|
let clawHubService!: ClawHubService;
|
||||||
const hostEventBus = new HostEventBus();
|
let hostEventBus!: HostEventBus;
|
||||||
let hostApiServer: Server | null = null;
|
let hostApiServer: Server | null = null;
|
||||||
|
const mainWindowFocusState = createMainWindowFocusState();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the icons directory path (works in both dev and packaged mode)
|
* Resolve the icons directory path (works in both dev and packaged mode)
|
||||||
@@ -122,11 +132,6 @@ function createWindow(): BrowserWindow {
|
|||||||
show: false,
|
show: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show window when ready to prevent visual flash
|
|
||||||
win.once('ready-to-show', () => {
|
|
||||||
win.show();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle external links
|
// Handle external links
|
||||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
@@ -144,6 +149,62 @@ function createWindow(): BrowserWindow {
|
|||||||
return win;
|
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
|
* Initialize the application
|
||||||
*/
|
*/
|
||||||
@@ -169,10 +230,10 @@ async function initialize(): Promise<void> {
|
|||||||
createMenu();
|
createMenu();
|
||||||
|
|
||||||
// Create the main window
|
// Create the main window
|
||||||
mainWindow = createWindow();
|
const window = createMainWindow();
|
||||||
|
|
||||||
// Create system tray
|
// Create system tray
|
||||||
createTray(mainWindow);
|
createTray(window);
|
||||||
|
|
||||||
// Override security headers ONLY for the OpenClaw Gateway Control UI.
|
// Override security headers ONLY for the OpenClaw Gateway Control UI.
|
||||||
// The URL filter ensures this callback only fires for gateway requests,
|
// The URL filter ensures this callback only fires for gateway requests,
|
||||||
@@ -198,33 +259,21 @@ async function initialize(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Register IPC handlers
|
// Register IPC handlers
|
||||||
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
|
registerIpcHandlers(gatewayManager, clawHubService, window);
|
||||||
|
|
||||||
hostApiServer = startHostApiServer({
|
hostApiServer = startHostApiServer({
|
||||||
gatewayManager,
|
gatewayManager,
|
||||||
clawHubService,
|
clawHubService,
|
||||||
eventBus: hostEventBus,
|
eventBus: hostEventBus,
|
||||||
mainWindow,
|
mainWindow: window,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register update handlers
|
// Register update handlers
|
||||||
registerUpdateHandlers(appUpdater, mainWindow);
|
registerUpdateHandlers(appUpdater, window);
|
||||||
|
|
||||||
// Note: Auto-check for updates is driven by the renderer (update store init)
|
// Note: Auto-check for updates is driven by the renderer (update store init)
|
||||||
// so it respects the user's "Auto-check for updates" setting.
|
// 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
|
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw
|
||||||
// template content). This fixes a race condition where ensureClawXContext()
|
// template content). This fixes a race condition where ensureClawXContext()
|
||||||
// previously created the file before the gateway could seed the full template.
|
// 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.
|
if (gotTheLock) {
|
||||||
app.on('second-instance', () => {
|
if (process.platform === 'win32') {
|
||||||
if (mainWindow) {
|
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
||||||
mainWindow.show();
|
|
||||||
mainWindow.focus();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Application lifecycle
|
gatewayManager = new GatewayManager();
|
||||||
app.whenReady().then(() => {
|
clawHubService = new ClawHubService();
|
||||||
void initialize().catch((error) => {
|
hostEventBus = new HostEventBus();
|
||||||
logger.error('Application initialization failed:', error);
|
|
||||||
|
// 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
|
// Application lifecycle
|
||||||
// "Cannot create BrowserWindow before app is ready" on macOS.
|
app.whenReady().then(() => {
|
||||||
app.on('activate', () => {
|
void initialize().catch((error) => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
logger.error('Application initialization failed:', error);
|
||||||
mainWindow = createWindow();
|
});
|
||||||
} else if (mainWindow && !mainWindow.isDestroyed()) {
|
|
||||||
// On macOS, clicking the dock icon should show the window if it's hidden
|
// Register activate handler AFTER app is ready to prevent
|
||||||
mainWindow.show();
|
// "Cannot create BrowserWindow before app is ready" on macOS.
|
||||||
mainWindow.focus();
|
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', () => {
|
app.on('before-quit', () => {
|
||||||
if (process.platform !== 'darwin') {
|
setQuitting();
|
||||||
app.quit();
|
hostEventBus.closeAll();
|
||||||
}
|
hostApiServer?.close();
|
||||||
});
|
// Fire-and-forget: do not await gatewayManager.stop() here.
|
||||||
|
// Awaiting inside before-quit can stall Electron's quit sequence.
|
||||||
app.on('before-quit', () => {
|
void gatewayManager.stop().catch((err) => {
|
||||||
setQuitting();
|
logger.warn('gatewayManager.stop() error during quit:', err);
|
||||||
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 for testing
|
||||||
export { mainWindow, gatewayManager };
|
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