From d55305839f170de6ea69f420c4a8a29064c9ed2c Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Tue, 24 Feb 2026 16:41:53 +0800 Subject: [PATCH] feat(updater): implement auto-install countdown and cancellation for updates (#151) --- electron/gateway/manager.ts | 46 ++++++++++++++------ electron/main/updater.ts | 50 ++++++++++++++++++++++ electron/preload/index.ts | 3 ++ src/components/settings/UpdateSettings.tsx | 15 ++++++- src/i18n/locales/en/settings.json | 8 ++-- src/i18n/locales/ja/settings.json | 27 +++++++++++- src/i18n/locales/zh/settings.json | 8 ++-- src/stores/update.ts | 17 ++++++++ 8 files changed, 153 insertions(+), 21 deletions(-) diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 72c6ed0d2..b3ec46872 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -826,15 +826,12 @@ export class GatewayManager extends EventEmitter { reject(err); }; - this.ws.on('open', async () => { - logger.debug('Gateway WebSocket opened, sending connect handshake'); - - // Re-fetch token here before generating payload just in case it updated while connecting + // Sends the connect frame using the server-issued challenge nonce. + const sendConnectHandshake = async (challengeNonce: string) => { + logger.debug('Sending connect handshake with challenge nonce'); + const currentToken = await getSetting('gatewayToken'); - - // Send proper connect handshake as required by OpenClaw Gateway protocol - // The Gateway expects: { type: "req", id: "...", method: "connect", params: ConnectParams } - // Since 2026.2.15, scopes are only granted when a signed device identity is included. + connectId = `connect-${Date.now()}`; const role = 'operator'; const scopes = ['operator.admin']; @@ -844,7 +841,7 @@ export class GatewayManager extends EventEmitter { const device = (() => { if (!this.deviceIdentity) return undefined; - + const payload = buildDeviceAuthPayload({ deviceId: this.deviceIdentity.deviceId, clientId, @@ -853,6 +850,7 @@ export class GatewayManager extends EventEmitter { scopes, signedAtMs, token: currentToken ?? null, + nonce: challengeNonce, }); const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload); return { @@ -860,6 +858,7 @@ export class GatewayManager extends EventEmitter { publicKey: publicKeyRawBase64UrlFromPem(this.deviceIdentity.publicKeyPem), signature, signedAt: signedAtMs, + nonce: challengeNonce, }; })(); @@ -886,10 +885,9 @@ export class GatewayManager extends EventEmitter { device, }, }; - + this.ws?.send(JSON.stringify(connectFrame)); - - // Store pending connect request + const requestTimeout = setTimeout(() => { if (!handshakeComplete) { logger.error('Gateway connect handshake timed out'); @@ -917,11 +915,35 @@ export class GatewayManager extends EventEmitter { }, timeout: requestTimeout, }); + }; + + this.ws.on('open', () => { + logger.debug('Gateway WebSocket opened, waiting for connect.challenge...'); }); + let challengeReceived = false; + this.ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); + + // Intercept the connect.challenge event before the general handler + if ( + !challengeReceived && + typeof message === 'object' && message !== null && + message.type === 'event' && message.event === 'connect.challenge' + ) { + challengeReceived = true; + const nonce = message.payload?.nonce as string | undefined; + if (!nonce) { + rejectOnce(new Error('Gateway connect.challenge missing nonce')); + return; + } + logger.debug('Received connect.challenge, sending handshake'); + sendConnectHandshake(nonce); + return; + } + this.handleMessage(message); } catch (error) { logger.debug('Failed to parse Gateway WebSocket message:', error); diff --git a/electron/main/updater.ts b/electron/main/updater.ts index 51578d038..62584c27f 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -42,6 +42,11 @@ function detectChannel(version: string): string { export class AppUpdater extends EventEmitter { private mainWindow: BrowserWindow | null = null; private status: UpdateStatus = { status: 'idle' }; + private autoInstallTimer: NodeJS.Timeout | null = null; + private autoInstallCountdown = 0; + + /** Delay (in seconds) before auto-installing a downloaded update. */ + private static readonly AUTO_INSTALL_DELAY_SECONDS = 5; constructor() { super(); @@ -120,6 +125,10 @@ export class AppUpdater extends EventEmitter { autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => { this.updateStatus({ status: 'downloaded', info: event }); this.emit('update-downloaded', event); + + if (autoUpdater.autoDownload) { + this.startAutoInstallCountdown(); + } }); autoUpdater.on('error', (error: Error) => { @@ -200,6 +209,41 @@ export class AppUpdater extends EventEmitter { autoUpdater.quitAndInstall(); } + /** + * Start a countdown that auto-installs the downloaded update. + * Sends `update:auto-install-countdown` events to the renderer each second. + */ + private startAutoInstallCountdown(): void { + this.clearAutoInstallTimer(); + this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS; + this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown }); + + this.autoInstallTimer = setInterval(() => { + this.autoInstallCountdown--; + this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown }); + + if (this.autoInstallCountdown <= 0) { + this.clearAutoInstallTimer(); + this.quitAndInstall(); + } + }, 1000); + } + + /** + * Cancel a running auto-install countdown. + */ + cancelAutoInstall(): void { + this.clearAutoInstallTimer(); + this.sendToRenderer('update:auto-install-countdown', { seconds: -1, cancelled: true }); + } + + private clearAutoInstallTimer(): void { + if (this.autoInstallTimer) { + clearInterval(this.autoInstallTimer); + this.autoInstallTimer = null; + } + } + /** * Set update channel (stable, beta, dev) */ @@ -280,6 +324,12 @@ export function registerUpdateHandlers( return { success: true }; }); + // Cancel pending auto-install countdown + ipcMain.handle('update:cancelAutoInstall', () => { + updater.cancelAutoInstall(); + return { success: true }; + }); + } // Export singleton instance diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 98a2e39c6..f4a6ab0f1 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -59,6 +59,7 @@ const electronAPI = { 'update:install', 'update:setChannel', 'update:setAutoDownload', + 'update:cancelAutoInstall', // Env 'env:getConfig', 'env:setApiKey', @@ -160,6 +161,7 @@ const electronAPI = { 'update:progress', 'update:downloaded', 'update:error', + 'update:auto-install-countdown', 'cron:updated', ]; @@ -199,6 +201,7 @@ const electronAPI = { 'update:progress', 'update:downloaded', 'update:error', + 'update:auto-install-countdown', ]; if (validChannels.includes(channel)) { diff --git a/src/components/settings/UpdateSettings.tsx b/src/components/settings/UpdateSettings.tsx index b3ee9b05a..291578298 100644 --- a/src/components/settings/UpdateSettings.tsx +++ b/src/components/settings/UpdateSettings.tsx @@ -3,7 +3,7 @@ * Displays update status and allows manual update checking/installation */ import { useEffect, useCallback } from 'react'; -import { Download, RefreshCw, Loader2, Rocket } from 'lucide-react'; +import { Download, RefreshCw, Loader2, Rocket, XCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { useUpdateStore } from '@/stores/update'; @@ -26,10 +26,12 @@ export function UpdateSettings() { progress, error, isInitialized, + autoInstallCountdown, init, checkForUpdates, downloadUpdate, installUpdate, + cancelAutoInstall, clearError, } = useUpdateStore(); @@ -60,6 +62,9 @@ export function UpdateSettings() { }; const renderStatusText = () => { + if (status === 'downloaded' && autoInstallCountdown != null && autoInstallCountdown >= 0) { + return t('updates.status.autoInstalling', { seconds: autoInstallCountdown }); + } switch (status) { case 'checking': return t('updates.status.checking'); @@ -102,6 +107,14 @@ export function UpdateSettings() { ); case 'downloaded': + if (autoInstallCountdown != null && autoInstallCountdown >= 0) { + return ( + + ); + } return (