Skip to content

Commit d553058

Browse files
authored
feat(updater): implement auto-install countdown and cancellation for updates (#151)
1 parent d572176 commit d553058

8 files changed

Lines changed: 153 additions & 21 deletions

File tree

electron/gateway/manager.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -826,15 +826,12 @@ export class GatewayManager extends EventEmitter {
826826
reject(err);
827827
};
828828

829-
this.ws.on('open', async () => {
830-
logger.debug('Gateway WebSocket opened, sending connect handshake');
831-
832-
// Re-fetch token here before generating payload just in case it updated while connecting
829+
// Sends the connect frame using the server-issued challenge nonce.
830+
const sendConnectHandshake = async (challengeNonce: string) => {
831+
logger.debug('Sending connect handshake with challenge nonce');
832+
833833
const currentToken = await getSetting('gatewayToken');
834-
835-
// Send proper connect handshake as required by OpenClaw Gateway protocol
836-
// The Gateway expects: { type: "req", id: "...", method: "connect", params: ConnectParams }
837-
// Since 2026.2.15, scopes are only granted when a signed device identity is included.
834+
838835
connectId = `connect-${Date.now()}`;
839836
const role = 'operator';
840837
const scopes = ['operator.admin'];
@@ -844,7 +841,7 @@ export class GatewayManager extends EventEmitter {
844841

845842
const device = (() => {
846843
if (!this.deviceIdentity) return undefined;
847-
844+
848845
const payload = buildDeviceAuthPayload({
849846
deviceId: this.deviceIdentity.deviceId,
850847
clientId,
@@ -853,13 +850,15 @@ export class GatewayManager extends EventEmitter {
853850
scopes,
854851
signedAtMs,
855852
token: currentToken ?? null,
853+
nonce: challengeNonce,
856854
});
857855
const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload);
858856
return {
859857
id: this.deviceIdentity.deviceId,
860858
publicKey: publicKeyRawBase64UrlFromPem(this.deviceIdentity.publicKeyPem),
861859
signature,
862860
signedAt: signedAtMs,
861+
nonce: challengeNonce,
863862
};
864863
})();
865864

@@ -886,10 +885,9 @@ export class GatewayManager extends EventEmitter {
886885
device,
887886
},
888887
};
889-
888+
890889
this.ws?.send(JSON.stringify(connectFrame));
891-
892-
// Store pending connect request
890+
893891
const requestTimeout = setTimeout(() => {
894892
if (!handshakeComplete) {
895893
logger.error('Gateway connect handshake timed out');
@@ -917,11 +915,35 @@ export class GatewayManager extends EventEmitter {
917915
},
918916
timeout: requestTimeout,
919917
});
918+
};
919+
920+
this.ws.on('open', () => {
921+
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
920922
});
921923

924+
let challengeReceived = false;
925+
922926
this.ws.on('message', (data) => {
923927
try {
924928
const message = JSON.parse(data.toString());
929+
930+
// Intercept the connect.challenge event before the general handler
931+
if (
932+
!challengeReceived &&
933+
typeof message === 'object' && message !== null &&
934+
message.type === 'event' && message.event === 'connect.challenge'
935+
) {
936+
challengeReceived = true;
937+
const nonce = message.payload?.nonce as string | undefined;
938+
if (!nonce) {
939+
rejectOnce(new Error('Gateway connect.challenge missing nonce'));
940+
return;
941+
}
942+
logger.debug('Received connect.challenge, sending handshake');
943+
sendConnectHandshake(nonce);
944+
return;
945+
}
946+
925947
this.handleMessage(message);
926948
} catch (error) {
927949
logger.debug('Failed to parse Gateway WebSocket message:', error);

electron/main/updater.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ function detectChannel(version: string): string {
4242
export class AppUpdater extends EventEmitter {
4343
private mainWindow: BrowserWindow | null = null;
4444
private status: UpdateStatus = { status: 'idle' };
45+
private autoInstallTimer: NodeJS.Timeout | null = null;
46+
private autoInstallCountdown = 0;
47+
48+
/** Delay (in seconds) before auto-installing a downloaded update. */
49+
private static readonly AUTO_INSTALL_DELAY_SECONDS = 5;
4550

4651
constructor() {
4752
super();
@@ -120,6 +125,10 @@ export class AppUpdater extends EventEmitter {
120125
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
121126
this.updateStatus({ status: 'downloaded', info: event });
122127
this.emit('update-downloaded', event);
128+
129+
if (autoUpdater.autoDownload) {
130+
this.startAutoInstallCountdown();
131+
}
123132
});
124133

125134
autoUpdater.on('error', (error: Error) => {
@@ -200,6 +209,41 @@ export class AppUpdater extends EventEmitter {
200209
autoUpdater.quitAndInstall();
201210
}
202211

212+
/**
213+
* Start a countdown that auto-installs the downloaded update.
214+
* Sends `update:auto-install-countdown` events to the renderer each second.
215+
*/
216+
private startAutoInstallCountdown(): void {
217+
this.clearAutoInstallTimer();
218+
this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS;
219+
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
220+
221+
this.autoInstallTimer = setInterval(() => {
222+
this.autoInstallCountdown--;
223+
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
224+
225+
if (this.autoInstallCountdown <= 0) {
226+
this.clearAutoInstallTimer();
227+
this.quitAndInstall();
228+
}
229+
}, 1000);
230+
}
231+
232+
/**
233+
* Cancel a running auto-install countdown.
234+
*/
235+
cancelAutoInstall(): void {
236+
this.clearAutoInstallTimer();
237+
this.sendToRenderer('update:auto-install-countdown', { seconds: -1, cancelled: true });
238+
}
239+
240+
private clearAutoInstallTimer(): void {
241+
if (this.autoInstallTimer) {
242+
clearInterval(this.autoInstallTimer);
243+
this.autoInstallTimer = null;
244+
}
245+
}
246+
203247
/**
204248
* Set update channel (stable, beta, dev)
205249
*/
@@ -280,6 +324,12 @@ export function registerUpdateHandlers(
280324
return { success: true };
281325
});
282326

327+
// Cancel pending auto-install countdown
328+
ipcMain.handle('update:cancelAutoInstall', () => {
329+
updater.cancelAutoInstall();
330+
return { success: true };
331+
});
332+
283333
}
284334

285335
// Export singleton instance

electron/preload/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const electronAPI = {
5959
'update:install',
6060
'update:setChannel',
6161
'update:setAutoDownload',
62+
'update:cancelAutoInstall',
6263
// Env
6364
'env:getConfig',
6465
'env:setApiKey',
@@ -160,6 +161,7 @@ const electronAPI = {
160161
'update:progress',
161162
'update:downloaded',
162163
'update:error',
164+
'update:auto-install-countdown',
163165
'cron:updated',
164166
];
165167

@@ -199,6 +201,7 @@ const electronAPI = {
199201
'update:progress',
200202
'update:downloaded',
201203
'update:error',
204+
'update:auto-install-countdown',
202205
];
203206

204207
if (validChannels.includes(channel)) {

src/components/settings/UpdateSettings.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Displays update status and allows manual update checking/installation
44
*/
55
import { useEffect, useCallback } from 'react';
6-
import { Download, RefreshCw, Loader2, Rocket } from 'lucide-react';
6+
import { Download, RefreshCw, Loader2, Rocket, XCircle } from 'lucide-react';
77
import { Button } from '@/components/ui/button';
88
import { Progress } from '@/components/ui/progress';
99
import { useUpdateStore } from '@/stores/update';
@@ -26,10 +26,12 @@ export function UpdateSettings() {
2626
progress,
2727
error,
2828
isInitialized,
29+
autoInstallCountdown,
2930
init,
3031
checkForUpdates,
3132
downloadUpdate,
3233
installUpdate,
34+
cancelAutoInstall,
3335
clearError,
3436
} = useUpdateStore();
3537

@@ -60,6 +62,9 @@ export function UpdateSettings() {
6062
};
6163

6264
const renderStatusText = () => {
65+
if (status === 'downloaded' && autoInstallCountdown != null && autoInstallCountdown >= 0) {
66+
return t('updates.status.autoInstalling', { seconds: autoInstallCountdown });
67+
}
6368
switch (status) {
6469
case 'checking':
6570
return t('updates.status.checking');
@@ -102,6 +107,14 @@ export function UpdateSettings() {
102107
</Button>
103108
);
104109
case 'downloaded':
110+
if (autoInstallCountdown != null && autoInstallCountdown >= 0) {
111+
return (
112+
<Button onClick={cancelAutoInstall} size="sm" variant="outline">
113+
<XCircle className="h-4 w-4 mr-2" />
114+
{t('updates.action.cancelAutoInstall')}
115+
</Button>
116+
);
117+
}
105118
return (
106119
<Button onClick={installUpdate} size="sm" variant="default">
107120
<Rocket className="h-4 w-4 mr-2" />

src/i18n/locales/en/settings.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,14 @@
7272
"description": "Keep ClawX up to date",
7373
"autoCheck": "Auto-check for updates",
7474
"autoCheckDesc": "Check for updates on startup",
75-
"autoDownload": "Auto-download updates",
76-
"autoDownloadDesc": "Download updates in the background",
75+
"autoDownload": "Auto-update",
76+
"autoDownloadDesc": "Automatically download and install updates",
7777
"status": {
7878
"checking": "Checking for updates...",
7979
"downloading": "Downloading update...",
8080
"available": "Update available: v{{version}}",
8181
"downloaded": "Ready to install: v{{version}}",
82+
"autoInstalling": "Restarting to install update in {{seconds}}s...",
8283
"failed": "Update check failed",
8384
"latest": "You have the latest version",
8485
"check": "Check for updates to get the latest features"
@@ -88,13 +89,14 @@
8889
"downloading": "Downloading...",
8990
"download": "Download Update",
9091
"install": "Install & Restart",
92+
"cancelAutoInstall": "Cancel",
9193
"retry": "Retry",
9294
"check": "Check for Updates"
9395
},
9496
"currentVersion": "Current Version",
9597
"whatsNew": "What's New:",
9698
"errorDetails": "Error Details:",
97-
"help": "Updates are downloaded in the background and installed when you restart the app."
99+
"help": "When auto-update is enabled, updates are downloaded and installed automatically."
98100
},
99101
"advanced": {
100102
"title": "Advanced",

src/i18n/locales/ja/settings.json

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,31 @@
7272
"description": "ClawX を最新に保つ",
7373
"autoCheck": "自動更新チェック",
7474
"autoCheckDesc": "起動時に更新を確認",
75-
"autoDownload": "自動ダウンロード",
76-
"autoDownloadDesc": "バックグラウンドで更新をダウンロード"
75+
"autoDownload": "自動アップデート",
76+
"autoDownloadDesc": "更新を自動的にダウンロードしてインストール",
77+
"status": {
78+
"checking": "更新を確認中...",
79+
"downloading": "更新をダウンロード中...",
80+
"available": "更新あり: v{{version}}",
81+
"downloaded": "インストール準備完了: v{{version}}",
82+
"autoInstalling": "{{seconds}} 秒後に再起動して更新をインストールします...",
83+
"failed": "更新の確認に失敗しました",
84+
"latest": "最新バージョンです",
85+
"check": "更新を確認して最新の機能を入手"
86+
},
87+
"action": {
88+
"checking": "確認中...",
89+
"downloading": "ダウンロード中...",
90+
"download": "更新をダウンロード",
91+
"install": "インストールして再起動",
92+
"cancelAutoInstall": "キャンセル",
93+
"retry": "再試行",
94+
"check": "更新を確認"
95+
},
96+
"currentVersion": "現在のバージョン",
97+
"whatsNew": "更新内容:",
98+
"errorDetails": "エラー詳細:",
99+
"help": "自動アップデートが有効な場合、更新は自動的にダウンロードされインストールされます。"
77100
},
78101
"advanced": {
79102
"title": "詳細設定",

src/i18n/locales/zh/settings.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,14 @@
7272
"description": "保持 ClawX 最新",
7373
"autoCheck": "自动检查更新",
7474
"autoCheckDesc": "启动时检查更新",
75-
"autoDownload": "自动下载更新",
76-
"autoDownloadDesc": "在后台下载更新",
75+
"autoDownload": "自动更新",
76+
"autoDownloadDesc": "自动下载并安装更新",
7777
"status": {
7878
"checking": "正在检查更新...",
7979
"downloading": "正在下载更新...",
8080
"available": "可用更新:v{{version}}",
8181
"downloaded": "准备安装:v{{version}}",
82+
"autoInstalling": "将在 {{seconds}} 秒后重启并安装更新...",
8283
"failed": "检查更新失败",
8384
"latest": "您已拥有最新版本",
8485
"check": "检查更新以获取最新功能"
@@ -88,13 +89,14 @@
8889
"downloading": "下载中...",
8990
"download": "下载更新",
9091
"install": "安装并重启",
92+
"cancelAutoInstall": "取消",
9193
"retry": "重试",
9294
"check": "检查更新"
9395
},
9496
"currentVersion": "当前版本",
9597
"whatsNew": "更新内容:",
9698
"errorDetails": "错误详情:",
97-
"help": "更新将在后台下载,并在您重启应用时安装"
99+
"help": "开启自动更新后,更新将自动下载并安装"
98100
},
99101
"advanced": {
100102
"title": "高级",

src/stores/update.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ interface UpdateState {
3535
progress: ProgressInfo | null;
3636
error: string | null;
3737
isInitialized: boolean;
38+
/** Seconds remaining before auto-install, or null if inactive. */
39+
autoInstallCountdown: number | null;
3840

3941
// Actions
4042
init: () => Promise<void>;
4143
checkForUpdates: () => Promise<void>;
4244
downloadUpdate: () => Promise<void>;
4345
installUpdate: () => void;
46+
cancelAutoInstall: () => Promise<void>;
4447
setChannel: (channel: 'stable' | 'beta' | 'dev') => Promise<void>;
4548
setAutoDownload: (enable: boolean) => Promise<void>;
4649
clearError: () => void;
@@ -53,6 +56,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
5356
progress: null,
5457
error: null,
5558
isInitialized: false,
59+
autoInstallCountdown: null,
5660

5761
init: async () => {
5862
if (get().isInitialized) return;
@@ -101,6 +105,11 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
101105
});
102106
});
103107

108+
window.electron.ipcRenderer.on('update:auto-install-countdown', (data) => {
109+
const { seconds, cancelled } = data as { seconds: number; cancelled?: boolean };
110+
set({ autoInstallCountdown: cancelled ? null : seconds });
111+
});
112+
104113
set({ isInitialized: true });
105114

106115
// Apply persisted settings from the settings store
@@ -180,6 +189,14 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
180189
window.electron.ipcRenderer.invoke('update:install');
181190
},
182191

192+
cancelAutoInstall: async () => {
193+
try {
194+
await window.electron.ipcRenderer.invoke('update:cancelAutoInstall');
195+
} catch (error) {
196+
console.error('Failed to cancel auto-install:', error);
197+
}
198+
},
199+
183200
setChannel: async (channel) => {
184201
try {
185202
await window.electron.ipcRenderer.invoke('update:setChannel', channel);

0 commit comments

Comments
 (0)