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);
|
reject(err);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.on('open', async () => {
|
// Sends the connect frame using the server-issued challenge nonce.
|
||||||
logger.debug('Gateway WebSocket opened, sending connect handshake');
|
const sendConnectHandshake = async (challengeNonce: string) => {
|
||||||
|
logger.debug('Sending connect handshake with challenge nonce');
|
||||||
// Re-fetch token here before generating payload just in case it updated while connecting
|
|
||||||
const currentToken = await getSetting('gatewayToken');
|
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()}`;
|
connectId = `connect-${Date.now()}`;
|
||||||
const role = 'operator';
|
const role = 'operator';
|
||||||
const scopes = ['operator.admin'];
|
const scopes = ['operator.admin'];
|
||||||
@@ -844,7 +841,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
|
|
||||||
const device = (() => {
|
const device = (() => {
|
||||||
if (!this.deviceIdentity) return undefined;
|
if (!this.deviceIdentity) return undefined;
|
||||||
|
|
||||||
const payload = buildDeviceAuthPayload({
|
const payload = buildDeviceAuthPayload({
|
||||||
deviceId: this.deviceIdentity.deviceId,
|
deviceId: this.deviceIdentity.deviceId,
|
||||||
clientId,
|
clientId,
|
||||||
@@ -853,6 +850,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
scopes,
|
scopes,
|
||||||
signedAtMs,
|
signedAtMs,
|
||||||
token: currentToken ?? null,
|
token: currentToken ?? null,
|
||||||
|
nonce: challengeNonce,
|
||||||
});
|
});
|
||||||
const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload);
|
const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload);
|
||||||
return {
|
return {
|
||||||
@@ -860,6 +858,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
publicKey: publicKeyRawBase64UrlFromPem(this.deviceIdentity.publicKeyPem),
|
publicKey: publicKeyRawBase64UrlFromPem(this.deviceIdentity.publicKeyPem),
|
||||||
signature,
|
signature,
|
||||||
signedAt: signedAtMs,
|
signedAt: signedAtMs,
|
||||||
|
nonce: challengeNonce,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -886,10 +885,9 @@ export class GatewayManager extends EventEmitter {
|
|||||||
device,
|
device,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws?.send(JSON.stringify(connectFrame));
|
this.ws?.send(JSON.stringify(connectFrame));
|
||||||
|
|
||||||
// Store pending connect request
|
|
||||||
const requestTimeout = setTimeout(() => {
|
const requestTimeout = setTimeout(() => {
|
||||||
if (!handshakeComplete) {
|
if (!handshakeComplete) {
|
||||||
logger.error('Gateway connect handshake timed out');
|
logger.error('Gateway connect handshake timed out');
|
||||||
@@ -917,11 +915,35 @@ export class GatewayManager extends EventEmitter {
|
|||||||
},
|
},
|
||||||
timeout: requestTimeout,
|
timeout: requestTimeout,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.on('open', () => {
|
||||||
|
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let challengeReceived = false;
|
||||||
|
|
||||||
this.ws.on('message', (data) => {
|
this.ws.on('message', (data) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data.toString());
|
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);
|
this.handleMessage(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug('Failed to parse Gateway WebSocket message:', error);
|
logger.debug('Failed to parse Gateway WebSocket message:', error);
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ function detectChannel(version: string): string {
|
|||||||
export class AppUpdater extends EventEmitter {
|
export class AppUpdater extends EventEmitter {
|
||||||
private mainWindow: BrowserWindow | null = null;
|
private mainWindow: BrowserWindow | null = null;
|
||||||
private status: UpdateStatus = { status: 'idle' };
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -120,6 +125,10 @@ export class AppUpdater extends EventEmitter {
|
|||||||
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
|
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
|
||||||
this.updateStatus({ status: 'downloaded', info: event });
|
this.updateStatus({ status: 'downloaded', info: event });
|
||||||
this.emit('update-downloaded', event);
|
this.emit('update-downloaded', event);
|
||||||
|
|
||||||
|
if (autoUpdater.autoDownload) {
|
||||||
|
this.startAutoInstallCountdown();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('error', (error: Error) => {
|
autoUpdater.on('error', (error: Error) => {
|
||||||
@@ -200,6 +209,41 @@ export class AppUpdater extends EventEmitter {
|
|||||||
autoUpdater.quitAndInstall();
|
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)
|
* Set update channel (stable, beta, dev)
|
||||||
*/
|
*/
|
||||||
@@ -280,6 +324,12 @@ export function registerUpdateHandlers(
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cancel pending auto-install countdown
|
||||||
|
ipcMain.handle('update:cancelAutoInstall', () => {
|
||||||
|
updater.cancelAutoInstall();
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const electronAPI = {
|
|||||||
'update:install',
|
'update:install',
|
||||||
'update:setChannel',
|
'update:setChannel',
|
||||||
'update:setAutoDownload',
|
'update:setAutoDownload',
|
||||||
|
'update:cancelAutoInstall',
|
||||||
// Env
|
// Env
|
||||||
'env:getConfig',
|
'env:getConfig',
|
||||||
'env:setApiKey',
|
'env:setApiKey',
|
||||||
@@ -160,6 +161,7 @@ const electronAPI = {
|
|||||||
'update:progress',
|
'update:progress',
|
||||||
'update:downloaded',
|
'update:downloaded',
|
||||||
'update:error',
|
'update:error',
|
||||||
|
'update:auto-install-countdown',
|
||||||
'cron:updated',
|
'cron:updated',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -199,6 +201,7 @@ const electronAPI = {
|
|||||||
'update:progress',
|
'update:progress',
|
||||||
'update:downloaded',
|
'update:downloaded',
|
||||||
'update:error',
|
'update:error',
|
||||||
|
'update:auto-install-countdown',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (validChannels.includes(channel)) {
|
if (validChannels.includes(channel)) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Displays update status and allows manual update checking/installation
|
* Displays update status and allows manual update checking/installation
|
||||||
*/
|
*/
|
||||||
import { useEffect, useCallback } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { useUpdateStore } from '@/stores/update';
|
import { useUpdateStore } from '@/stores/update';
|
||||||
@@ -26,10 +26,12 @@ export function UpdateSettings() {
|
|||||||
progress,
|
progress,
|
||||||
error,
|
error,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
|
autoInstallCountdown,
|
||||||
init,
|
init,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
downloadUpdate,
|
downloadUpdate,
|
||||||
installUpdate,
|
installUpdate,
|
||||||
|
cancelAutoInstall,
|
||||||
clearError,
|
clearError,
|
||||||
} = useUpdateStore();
|
} = useUpdateStore();
|
||||||
|
|
||||||
@@ -60,6 +62,9 @@ export function UpdateSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderStatusText = () => {
|
const renderStatusText = () => {
|
||||||
|
if (status === 'downloaded' && autoInstallCountdown != null && autoInstallCountdown >= 0) {
|
||||||
|
return t('updates.status.autoInstalling', { seconds: autoInstallCountdown });
|
||||||
|
}
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'checking':
|
case 'checking':
|
||||||
return t('updates.status.checking');
|
return t('updates.status.checking');
|
||||||
@@ -102,6 +107,14 @@ export function UpdateSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
case 'downloaded':
|
case 'downloaded':
|
||||||
|
if (autoInstallCountdown != null && autoInstallCountdown >= 0) {
|
||||||
|
return (
|
||||||
|
<Button onClick={cancelAutoInstall} size="sm" variant="outline">
|
||||||
|
<XCircle className="h-4 w-4 mr-2" />
|
||||||
|
{t('updates.action.cancelAutoInstall')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Button onClick={installUpdate} size="sm" variant="default">
|
<Button onClick={installUpdate} size="sm" variant="default">
|
||||||
<Rocket className="h-4 w-4 mr-2" />
|
<Rocket className="h-4 w-4 mr-2" />
|
||||||
|
|||||||
@@ -72,13 +72,14 @@
|
|||||||
"description": "Keep ClawX up to date",
|
"description": "Keep ClawX up to date",
|
||||||
"autoCheck": "Auto-check for updates",
|
"autoCheck": "Auto-check for updates",
|
||||||
"autoCheckDesc": "Check for updates on startup",
|
"autoCheckDesc": "Check for updates on startup",
|
||||||
"autoDownload": "Auto-download updates",
|
"autoDownload": "Auto-update",
|
||||||
"autoDownloadDesc": "Download updates in the background",
|
"autoDownloadDesc": "Automatically download and install updates",
|
||||||
"status": {
|
"status": {
|
||||||
"checking": "Checking for updates...",
|
"checking": "Checking for updates...",
|
||||||
"downloading": "Downloading update...",
|
"downloading": "Downloading update...",
|
||||||
"available": "Update available: v{{version}}",
|
"available": "Update available: v{{version}}",
|
||||||
"downloaded": "Ready to install: v{{version}}",
|
"downloaded": "Ready to install: v{{version}}",
|
||||||
|
"autoInstalling": "Restarting to install update in {{seconds}}s...",
|
||||||
"failed": "Update check failed",
|
"failed": "Update check failed",
|
||||||
"latest": "You have the latest version",
|
"latest": "You have the latest version",
|
||||||
"check": "Check for updates to get the latest features"
|
"check": "Check for updates to get the latest features"
|
||||||
@@ -88,13 +89,14 @@
|
|||||||
"downloading": "Downloading...",
|
"downloading": "Downloading...",
|
||||||
"download": "Download Update",
|
"download": "Download Update",
|
||||||
"install": "Install & Restart",
|
"install": "Install & Restart",
|
||||||
|
"cancelAutoInstall": "Cancel",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"check": "Check for Updates"
|
"check": "Check for Updates"
|
||||||
},
|
},
|
||||||
"currentVersion": "Current Version",
|
"currentVersion": "Current Version",
|
||||||
"whatsNew": "What's New:",
|
"whatsNew": "What's New:",
|
||||||
"errorDetails": "Error Details:",
|
"errorDetails": "Error Details:",
|
||||||
"help": "Updates are downloaded in the background and installed when you restart the app."
|
"help": "When auto-update is enabled, updates are downloaded and installed automatically."
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"title": "Advanced",
|
"title": "Advanced",
|
||||||
|
|||||||
@@ -72,8 +72,31 @@
|
|||||||
"description": "ClawX を最新に保つ",
|
"description": "ClawX を最新に保つ",
|
||||||
"autoCheck": "自動更新チェック",
|
"autoCheck": "自動更新チェック",
|
||||||
"autoCheckDesc": "起動時に更新を確認",
|
"autoCheckDesc": "起動時に更新を確認",
|
||||||
"autoDownload": "自動ダウンロード",
|
"autoDownload": "自動アップデート",
|
||||||
"autoDownloadDesc": "バックグラウンドで更新をダウンロード"
|
"autoDownloadDesc": "更新を自動的にダウンロードしてインストール",
|
||||||
|
"status": {
|
||||||
|
"checking": "更新を確認中...",
|
||||||
|
"downloading": "更新をダウンロード中...",
|
||||||
|
"available": "更新あり: v{{version}}",
|
||||||
|
"downloaded": "インストール準備完了: v{{version}}",
|
||||||
|
"autoInstalling": "{{seconds}} 秒後に再起動して更新をインストールします...",
|
||||||
|
"failed": "更新の確認に失敗しました",
|
||||||
|
"latest": "最新バージョンです",
|
||||||
|
"check": "更新を確認して最新の機能を入手"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"checking": "確認中...",
|
||||||
|
"downloading": "ダウンロード中...",
|
||||||
|
"download": "更新をダウンロード",
|
||||||
|
"install": "インストールして再起動",
|
||||||
|
"cancelAutoInstall": "キャンセル",
|
||||||
|
"retry": "再試行",
|
||||||
|
"check": "更新を確認"
|
||||||
|
},
|
||||||
|
"currentVersion": "現在のバージョン",
|
||||||
|
"whatsNew": "更新内容:",
|
||||||
|
"errorDetails": "エラー詳細:",
|
||||||
|
"help": "自動アップデートが有効な場合、更新は自動的にダウンロードされインストールされます。"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"title": "詳細設定",
|
"title": "詳細設定",
|
||||||
|
|||||||
@@ -72,13 +72,14 @@
|
|||||||
"description": "保持 ClawX 最新",
|
"description": "保持 ClawX 最新",
|
||||||
"autoCheck": "自动检查更新",
|
"autoCheck": "自动检查更新",
|
||||||
"autoCheckDesc": "启动时检查更新",
|
"autoCheckDesc": "启动时检查更新",
|
||||||
"autoDownload": "自动下载更新",
|
"autoDownload": "自动更新",
|
||||||
"autoDownloadDesc": "在后台下载更新",
|
"autoDownloadDesc": "自动下载并安装更新",
|
||||||
"status": {
|
"status": {
|
||||||
"checking": "正在检查更新...",
|
"checking": "正在检查更新...",
|
||||||
"downloading": "正在下载更新...",
|
"downloading": "正在下载更新...",
|
||||||
"available": "可用更新:v{{version}}",
|
"available": "可用更新:v{{version}}",
|
||||||
"downloaded": "准备安装:v{{version}}",
|
"downloaded": "准备安装:v{{version}}",
|
||||||
|
"autoInstalling": "将在 {{seconds}} 秒后重启并安装更新...",
|
||||||
"failed": "检查更新失败",
|
"failed": "检查更新失败",
|
||||||
"latest": "您已拥有最新版本",
|
"latest": "您已拥有最新版本",
|
||||||
"check": "检查更新以获取最新功能"
|
"check": "检查更新以获取最新功能"
|
||||||
@@ -88,13 +89,14 @@
|
|||||||
"downloading": "下载中...",
|
"downloading": "下载中...",
|
||||||
"download": "下载更新",
|
"download": "下载更新",
|
||||||
"install": "安装并重启",
|
"install": "安装并重启",
|
||||||
|
"cancelAutoInstall": "取消",
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"check": "检查更新"
|
"check": "检查更新"
|
||||||
},
|
},
|
||||||
"currentVersion": "当前版本",
|
"currentVersion": "当前版本",
|
||||||
"whatsNew": "更新内容:",
|
"whatsNew": "更新内容:",
|
||||||
"errorDetails": "错误详情:",
|
"errorDetails": "错误详情:",
|
||||||
"help": "更新将在后台下载,并在您重启应用时安装。"
|
"help": "开启自动更新后,更新将自动下载并安装。"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"title": "高级",
|
"title": "高级",
|
||||||
|
|||||||
@@ -35,12 +35,15 @@ interface UpdateState {
|
|||||||
progress: ProgressInfo | null;
|
progress: ProgressInfo | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
|
/** Seconds remaining before auto-install, or null if inactive. */
|
||||||
|
autoInstallCountdown: number | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
init: () => Promise<void>;
|
init: () => Promise<void>;
|
||||||
checkForUpdates: () => Promise<void>;
|
checkForUpdates: () => Promise<void>;
|
||||||
downloadUpdate: () => Promise<void>;
|
downloadUpdate: () => Promise<void>;
|
||||||
installUpdate: () => void;
|
installUpdate: () => void;
|
||||||
|
cancelAutoInstall: () => Promise<void>;
|
||||||
setChannel: (channel: 'stable' | 'beta' | 'dev') => Promise<void>;
|
setChannel: (channel: 'stable' | 'beta' | 'dev') => Promise<void>;
|
||||||
setAutoDownload: (enable: boolean) => Promise<void>;
|
setAutoDownload: (enable: boolean) => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
@@ -53,6 +56,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
|||||||
progress: null,
|
progress: null,
|
||||||
error: null,
|
error: null,
|
||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
|
autoInstallCountdown: null,
|
||||||
|
|
||||||
init: async () => {
|
init: async () => {
|
||||||
if (get().isInitialized) return;
|
if (get().isInitialized) return;
|
||||||
@@ -101,6 +105,11 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.electron.ipcRenderer.on('update:auto-install-countdown', (data) => {
|
||||||
|
const { seconds, cancelled } = data as { seconds: number; cancelled?: boolean };
|
||||||
|
set({ autoInstallCountdown: cancelled ? null : seconds });
|
||||||
|
});
|
||||||
|
|
||||||
set({ isInitialized: true });
|
set({ isInitialized: true });
|
||||||
|
|
||||||
// Apply persisted settings from the settings store
|
// Apply persisted settings from the settings store
|
||||||
@@ -180,6 +189,14 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
|||||||
window.electron.ipcRenderer.invoke('update:install');
|
window.electron.ipcRenderer.invoke('update:install');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cancelAutoInstall: async () => {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('update:cancelAutoInstall');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to cancel auto-install:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
setChannel: async (channel) => {
|
setChannel: async (channel) => {
|
||||||
try {
|
try {
|
||||||
await window.electron.ipcRenderer.invoke('update:setChannel', channel);
|
await window.electron.ipcRenderer.invoke('update:setChannel', channel);
|
||||||
|
|||||||
Reference in New Issue
Block a user