feat(updater): implement auto-install countdown and cancellation for updates (#151)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user