feat(update): implement auto-update functionality with electron-updater

- Add AppUpdater module with update lifecycle management
- Create UpdateSettings UI component with progress display
- Add Progress UI component based on Radix UI
- Create update Zustand store for state management
- Register update IPC handlers in main process
- Auto-check for updates on production startup
- Add commit documentation for commits 2-6
This commit is contained in:
Haze
2026-02-05 23:36:12 +08:00
Unverified
parent 98a2d9bc83
commit e02cf05baf
13 changed files with 1313 additions and 50 deletions

View File

@@ -0,0 +1,83 @@
# Commit 2: Gateway Refinements
## Summary
Enhance Gateway process management with auto-reconnection, health checks, and improved state management for more robust WebSocket communication.
## Changes
### Electron Main Process
#### `electron/gateway/manager.ts`
- Added `'reconnecting'` state to `GatewayStatus`
- Implemented `ReconnectConfig` with exponential backoff strategy
- Added `maxAttempts`, `baseDelay`, `maxDelay` configuration
- New methods:
- `isConnected()` - Check WebSocket connection status
- `restart()` - Stop and start Gateway
- `clearAllTimers()` - Clean up timers on shutdown
- `startHealthCheck()` - Periodic health monitoring
- `checkHealth()` - Manual health check
- Enhanced `handleMessage()` to dispatch JSON-RPC responses and notifications
- Expanded `GatewayManagerEvents` for notification forwarding
#### `electron/gateway/client.ts`
- Extended with new interfaces: `SkillBundle`, `CronTask`, `ProviderConfig`
- Added cron task management methods:
- `listCronTasks()`, `createCronTask()`, `updateCronTask()`, `deleteCronTask()`, `runCronTask()`
- Added provider management methods:
- `listProviders()`, `setProvider()`, `removeProvider()`, `testProvider()`
- Enhanced system calls:
- `getHealth()` now includes `version`
- Added `getVersion()`, `getSkillBundles()`, `installBundle()`
#### `electron/main/ipc-handlers.ts`
- Added `gateway:isConnected`, `gateway:health` IPC handlers
- Added `mainWindow.isDestroyed()` checks before sending events
- Forward new gateway events: `gateway:notification`, `gateway:channel-status`, `gateway:chat-message`
#### `electron/preload/index.ts`
- Added new IPC channels for gateway operations
- Added notification event channels
### React Renderer
#### `src/stores/gateway.ts`
- Added `health: GatewayHealth | null`, `lastError: string | null` to state
- Added `checkHealth()`, `rpc()`, `clearError()` actions
- Enhanced `init()` to listen for error and notification events
#### `src/types/gateway.ts`
- Added `'reconnecting'` to `GatewayStatus` state enum
- Added `version`, `reconnectAttempts` fields
- New interfaces: `GatewayHealth`, `GatewayNotification`, `ProviderConfig`
#### `src/components/common/StatusBadge.tsx`
- Added `'reconnecting'` status with warning variant
## Technical Details
### Reconnection Strategy
- Exponential backoff: `delay = min(baseDelay * 2^attempt, maxDelay)`
- Default: 1s base delay, 30s max delay, 10 max attempts
- Automatic reconnection on unexpected disconnection
- Manual control via `shouldReconnect` flag
### Health Check
- Periodic ping/pong via JSON-RPC `system.health`
- Returns status, uptime, version information
- Triggers reconnection on consecutive failures
### Event Flow
```
Gateway Process -> WebSocket -> GatewayManager -> IPC -> Renderer
|
v
Event Emitter
|
+---------------+---------------+
| | |
status notification channel:status
```
## Version
v0.1.0-alpha (incremental)

View File

@@ -0,0 +1,88 @@
# Commit 3: Setup Wizard
## Summary
Implement a functional multi-step setup wizard for first-run user onboarding, guiding users through environment checks, AI provider configuration, channel connection, and skill selection.
## Changes
### React Renderer
#### `src/pages/Setup/index.tsx`
Complete rewrite with functional implementation:
- **Step 0: Welcome** - Introduction to ClawX
- **Step 1: Runtime Check** - Verify Node.js, OpenClaw, and Gateway status
- **Step 2: Provider Configuration** - Select AI provider and enter API key
- **Step 3: Channel Connection** - Choose messaging channel (WhatsApp, Telegram, etc.)
- **Step 4: Skill Selection** - Toggle predefined skill bundles
- **Step 5: Complete** - Summary of configuration
New components:
- `WelcomeContent` - Welcome message and features overview
- `RuntimeContent` - Environment checks with real Gateway status
- `ProviderContent` - Provider selection with API key input and validation
- `ChannelContent` - Channel type selection with QR code placeholder
- `SkillsContent` - Skill bundle toggles
- `CompleteContent` - Configuration summary
Features:
- Animated transitions using Framer Motion
- Progress indicator with step navigation
- Dynamic `canProceed` state based on step requirements
- API key visibility toggle
- Simulated API key validation
#### `src/stores/settings.ts`
- Added `setupComplete: boolean` to state
- Added `markSetupComplete()` action
- Setup status persisted via Zustand persist middleware
#### `src/App.tsx`
- Added `useLocation` for route tracking
- Added redirect logic: if `setupComplete` is false, navigate to `/setup`
- Fixed `handleNavigate` callback signature for IPC events
## Technical Details
### Setup Flow
```
Welcome -> Runtime -> Provider -> Channel -> Skills -> Complete
| | | | | |
v v v v v v
[Skip] [Check] [Validate] [Select] [Toggle] [Finish]
| | | | | |
+---------+----------+----------+----------+----------+
|
markSetupComplete()
|
Navigate to /
```
### Provider Types
- Anthropic (Claude) - API key starts with `sk-ant-`
- OpenAI (GPT) - API key starts with `sk-`
- Google (Gemini) - Length validation
- Ollama (Local) - No API key required
- Custom - Configurable base URL
### Channel Types
- WhatsApp - QR code connection
- Telegram - Bot token
- Discord - Bot token
- Slack - OAuth/Bot token
- WeChat - QR code connection
### Skill Bundles
- Productivity - Calendar, reminders, notes
- Developer - Code assistance, git operations
- Information - Web search, news, weather
- Entertainment - Music, games, trivia
## UI/UX Features
- Gradient background with glassmorphism cards
- Step progress dots with completion indicators
- Animated page transitions
- Back/Skip/Next navigation
- Toast notifications on completion
## Version
v0.1.0-alpha (incremental)

View File

@@ -0,0 +1,127 @@
# Commit 4: Provider Configuration
## Summary
Implement secure API key storage using Electron's safeStorage API and create a comprehensive provider management UI for configuring AI model providers.
## Changes
### Electron Main Process
#### `electron/utils/secure-storage.ts` (New)
Secure storage utility using Electron's safeStorage:
- `isEncryptionAvailable()` - Check if system keychain is available
- `storeApiKey(providerId, apiKey)` - Encrypt and store API key
- `getApiKey(providerId)` - Decrypt and retrieve API key
- `deleteApiKey(providerId)` - Remove stored API key
- `hasApiKey(providerId)` - Check if key exists
- `listStoredKeyIds()` - List all stored provider IDs
Provider configuration management:
- `saveProvider(config)` - Save provider configuration
- `getProvider(providerId)` - Get single provider
- `getAllProviders()` - Get all providers
- `deleteProvider(providerId)` - Delete provider and key
- `setDefaultProvider(providerId)` - Set default provider
- `getDefaultProvider()` - Get default provider ID
- `getProviderWithKeyInfo(providerId)` - Get provider with masked key
- `getAllProvidersWithKeyInfo()` - Get all providers with key info
Note: Uses dynamic `import('electron-store')` for ESM compatibility.
#### `electron/main/ipc-handlers.ts`
New `registerProviderHandlers()` function with IPC handlers:
- `provider:encryptionAvailable`
- `provider:list` / `provider:get` / `provider:save` / `provider:delete`
- `provider:setApiKey` / `provider:deleteApiKey` / `provider:hasApiKey` / `provider:getApiKey`
- `provider:setDefault` / `provider:getDefault`
- `provider:validateKey` - Basic format validation
#### `electron/preload/index.ts`
Added all provider IPC channels to valid channels list.
### React Renderer
#### `src/stores/providers.ts` (New)
Zustand store for provider management:
- State: `providers`, `defaultProviderId`, `loading`, `error`
- Actions: `fetchProviders`, `addProvider`, `updateProvider`, `deleteProvider`
- Actions: `setApiKey`, `deleteApiKey`, `setDefaultProvider`, `validateApiKey`, `getApiKey`
#### `src/components/settings/ProvidersSettings.tsx` (New)
Provider management UI component:
- `ProvidersSettings` - Main component orchestrating provider list
- `ProviderCard` - Individual provider display with actions
- `AddProviderDialog` - Modal for adding new providers
Features:
- Provider type icons (Anthropic, OpenAI, Google, Ollama, Custom)
- Masked API key display (first 4 + last 4 characters)
- Set default provider
- Enable/disable providers
- Edit/delete functionality
- API key validation on add
#### `src/pages/Settings/index.tsx`
- Added AI Providers section with `ProvidersSettings` component
- Added `Key` icon import
## Technical Details
### Security Architecture
```
Renderer Process Main Process
| |
| provider:setApiKey |
|--------------------------------->|
| | safeStorage.encryptString()
| | |
| | v
| | System Keychain
| | |
| | electron-store
| | (encrypted base64)
| |
| provider:getApiKey |
|--------------------------------->|
| | safeStorage.decryptString()
|<---------------------------------|
| (plaintext) |
```
### Provider Configuration Schema
```typescript
interface ProviderConfig {
id: string;
name: string;
type: 'anthropic' | 'openai' | 'google' | 'ollama' | 'custom';
baseUrl?: string;
model?: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
```
### Key Validation Rules
- Anthropic: Must start with `sk-ant-`
- OpenAI: Must start with `sk-`
- Google: Minimum 20 characters
- Ollama: No key required
- Custom: No validation
### ESM Compatibility
electron-store v10+ is ESM-only. Solution: dynamic imports in main process.
```typescript
let store: any = null;
async function getStore() {
if (!store) {
const Store = (await import('electron-store')).default;
store = new Store({ /* config */ });
}
return store;
}
```
## Version
v0.1.0-alpha (incremental)

View File

@@ -0,0 +1,124 @@
# Commit 5: Channel Connection Flows
## Summary
Implement comprehensive channel management UI with multi-platform support, including QR code-based connection for WhatsApp/WeChat and token-based connection for Telegram/Discord/Slack.
## Changes
### React Renderer
#### `src/pages/Channels/index.tsx`
Complete rewrite with enhanced functionality:
**Main Components:**
- `Channels` - Main page with channel list, stats, and add dialog
- `ChannelCard` - Individual channel display with connect/disconnect/delete
- `AddChannelDialog` - Multi-step channel addition flow
**Features:**
- Channel statistics (Total, Connected, Disconnected)
- Gateway status warning when not running
- Supported channel type grid for quick add
- Connection-type specific flows:
- QR Code: WhatsApp, WeChat
- Token: Telegram, Discord, Slack
**AddChannelDialog Flow:**
1. Select channel type
2. View connection instructions
3. For QR: Generate and display QR code
4. For Token: Enter bot token
5. Optionally name the channel
6. Confirm and add
**Channel Info Configuration:**
```typescript
const channelInfo: Record<ChannelType, {
description: string;
connectionType: 'qr' | 'token' | 'oauth';
instructions: string[];
tokenLabel?: string;
docsUrl?: string;
}>
```
#### `src/stores/channels.ts`
Enhanced channel store with new actions:
- `addChannel(params)` - Add new channel with type, name, token
- `deleteChannel(channelId)` - Remove channel
- `requestQrCode(channelType)` - Request QR code from Gateway
- `clearError()` - Clear error state
Improved error handling:
- Graceful fallback when Gateway unavailable
- Local channel creation for offline mode
#### `src/types/channel.ts`
No changes - existing types sufficient.
### Electron Main Process
#### `electron/utils/store.ts`
- Converted to dynamic imports for ESM compatibility
- All functions now async
#### `electron/main/window.ts`
- Converted to dynamic imports for ESM compatibility
- `getWindowState()` and `saveWindowState()` now async
## Technical Details
### Channel Connection Architecture
```
AddChannelDialog
|
+-- QR Flow
| |
| v
| requestQrCode() --> Gateway --> WhatsApp/WeChat API
| | |
| v v
| Display QR <-- qrCode string <-- QR Generated
| |
| v
| User Scans --> Device Confirms --> channel:status event
|
+-- Token Flow
|
v
Enter Token --> addChannel() --> Gateway --> Bot API
| |
v v
Validate <-- success/error <-- Connection Attempt
```
### Channel Types Configuration
| Type | Connection | Token Label | Docs |
|------|------------|-------------|------|
| WhatsApp | QR Code | - | WhatsApp FAQ |
| Telegram | Token | Bot Token | BotFather Docs |
| Discord | Token | Bot Token | Developer Portal |
| Slack | Token | Bot Token (xoxb-) | Slack API |
| WeChat | QR Code | - | - |
### Connection Instructions
Each channel type provides step-by-step instructions:
- WhatsApp: Open app > Settings > Linked Devices > Scan
- Telegram: @BotFather > /newbot > Copy token
- Discord: Developer Portal > Application > Bot > Token
- Slack: API Apps > Create App > OAuth > Install
### UI Components Used
- `Card`, `Button`, `Input`, `Label` - Base components
- `Separator` - Visual dividers
- `StatusBadge` - Connection status
- `LoadingSpinner` - Loading states
- Lucide icons: Plus, Radio, RefreshCw, Power, QrCode, etc.
### Error Handling
- Gateway offline: Create local channel, show warning
- Connection failed: Display error on ChannelCard
- Invalid token: Show validation error in dialog
## Version
v0.1.0-alpha (incremental)

View File

@@ -0,0 +1,171 @@
# Commit 6: Auto-Update Functionality
## Summary
Integrate electron-updater for automatic application updates with a comprehensive UI for checking, downloading, and installing updates.
## Changes
### Electron Main Process
#### `electron/main/updater.ts` (New)
Complete auto-update module:
**AppUpdater Class:**
- Extends EventEmitter for event-based notifications
- Configures electron-updater settings
- Manages update lifecycle
**Methods:**
- `setMainWindow(window)` - Set window for IPC
- `getStatus()` - Get current update status
- `checkForUpdates()` - Check for available updates
- `downloadUpdate()` - Download available update
- `quitAndInstall()` - Install update and restart
- `setChannel(channel)` - Set update channel (stable/beta/dev)
- `setAutoDownload(enable)` - Configure auto-download
- `getCurrentVersion()` - Get app version
**Update Status:**
- `idle` - No update activity
- `checking` - Checking for updates
- `available` - Update available
- `not-available` - Already on latest
- `downloading` - Download in progress
- `downloaded` - Ready to install
- `error` - Update failed
**IPC Handlers (registerUpdateHandlers):**
- `update:status` - Get current status
- `update:version` - Get app version
- `update:check` - Trigger update check
- `update:download` - Start download
- `update:install` - Install and restart
- `update:setChannel` - Change update channel
- `update:setAutoDownload` - Toggle auto-download
**Events forwarded to renderer:**
- `update:status-changed`
- `update:checking`
- `update:available`
- `update:not-available`
- `update:progress`
- `update:downloaded`
- `update:error`
#### `electron/main/index.ts`
- Import and register update handlers
- Auto-check for updates in production (10s delay)
#### `electron/preload/index.ts`
- Added all update IPC channels
### React Renderer
#### `src/stores/update.ts` (New)
Zustand store for update state:
- State: `status`, `currentVersion`, `updateInfo`, `progress`, `error`, `isInitialized`
- Actions: `init`, `checkForUpdates`, `downloadUpdate`, `installUpdate`, `setChannel`, `setAutoDownload`, `clearError`
- Listens for all update events from main process
#### `src/components/settings/UpdateSettings.tsx` (New)
Update UI component:
- Current version display
- Status indicator with icon
- Status text description
- Action buttons (Check/Download/Install/Retry)
- Download progress bar with transfer stats
- Update info card (version, date, release notes)
- Error details display
**Progress Display:**
- Transferred / Total bytes
- Transfer speed (bytes/second)
- Progress percentage bar
#### `src/components/ui/progress.tsx` (New)
Radix UI Progress component for download visualization.
#### `src/pages/Settings/index.tsx`
- Replaced manual update section with `UpdateSettings` component
- Added `useUpdateStore` for version display
- Removed unused state and effect hooks
## Technical Details
### Update Flow
```
App Start (Production)
|
v
10s Delay
|
v
checkForUpdates()
|
+-- No Update --> status: 'not-available'
|
+-- Update Found --> status: 'available'
|
v
[User Action]
|
v
downloadUpdate()
|
v
status: 'downloading'
progress events
|
v
status: 'downloaded'
|
v
[User Action]
|
v
quitAndInstall()
|
v
App Restarts
```
### electron-updater Configuration
```typescript
autoUpdater.autoDownload = false; // Manual download trigger
autoUpdater.autoInstallOnAppQuit = true; // Install on quit
autoUpdater.logger = customLogger; // Console logging
```
### Update Channels
- `stable` - Production releases
- `beta` - Pre-release testing
- `dev` - Development builds
### Progress Info Interface
```typescript
interface ProgressInfo {
total: number; // Total bytes
delta: number; // Bytes since last event
transferred: number; // Bytes downloaded
percent: number; // 0-100
bytesPerSecond: number; // Transfer speed
}
```
### UI States
| Status | Icon | Action Button |
|--------|------|---------------|
| idle | RefreshCw (gray) | Check for Updates |
| checking | Loader2 (spin, blue) | Checking... (disabled) |
| available | Download (green) | Download Update |
| downloading | Download (pulse, blue) | Downloading... (disabled) |
| downloaded | CheckCircle2 (green) | Install & Restart |
| error | AlertCircle (red) | Retry |
| not-available | CheckCircle2 (green) | Check for Updates |
## Dependencies
- electron-updater ^6.3.9 (already installed)
- @radix-ui/react-progress ^1.1.1 (already installed)
## Version
v0.1.0-alpha (incremental)

View File

@@ -11,6 +11,7 @@
* [commit_3] Setup wizard - Multi-step onboarding flow with provider, channel, skill selection
* [commit_4] Provider configuration - Secure API key storage, provider management UI
* [commit_5] Channel connection flows - Multi-channel support with QR/token connection UI
* [commit_6] Auto-update functionality - electron-updater integration with UI
### Plan:
1. ~~Initialize project structure~~
@@ -18,7 +19,7 @@
3. ~~Implement Setup wizard with actual functionality~~
4. ~~Add Provider configuration (API Key management)~~
5. ~~Implement Channel connection flows~~
6. Add auto-update functionality
6. ~~Add auto-update functionality~~
7. Packaging and distribution setup
## Version Milestones

View File

@@ -9,6 +9,7 @@ import { registerIpcHandlers } from './ipc-handlers';
import { createTray } from './tray';
import { createMenu } from './menu';
import { PORTS } from '../utils/config';
import { appUpdater, registerUpdateHandlers } from './updater';
// Disable GPU acceleration for better compatibility
app.disableHardwareAcceleration();
@@ -76,6 +77,18 @@ async function initialize(): Promise<void> {
// Register IPC handlers
registerIpcHandlers(gatewayManager, mainWindow);
// Register update handlers
registerUpdateHandlers(appUpdater, mainWindow);
// Check for updates after a delay (only in production)
if (!process.env.VITE_DEV_SERVER_URL) {
setTimeout(() => {
appUpdater.checkForUpdates().catch((err) => {
console.error('Failed to check for updates:', err);
});
}, 10000); // Check after 10 seconds
}
// Handle window close
mainWindow.on('closed', () => {
mainWindow = null;

264
electron/main/updater.ts Normal file
View File

@@ -0,0 +1,264 @@
/**
* Auto-Updater Module
* Handles automatic application updates using electron-updater
*/
import { autoUpdater, UpdateInfo, ProgressInfo, UpdateDownloadedEvent } from 'electron-updater';
import { BrowserWindow, app, ipcMain } from 'electron';
import { EventEmitter } from 'events';
export interface UpdateStatus {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: UpdateInfo;
progress?: ProgressInfo;
error?: string;
}
export interface UpdaterEvents {
'status-changed': (status: UpdateStatus) => void;
'checking-for-update': () => void;
'update-available': (info: UpdateInfo) => void;
'update-not-available': (info: UpdateInfo) => void;
'download-progress': (progress: ProgressInfo) => void;
'update-downloaded': (event: UpdateDownloadedEvent) => void;
'error': (error: Error) => void;
}
export class AppUpdater extends EventEmitter {
private mainWindow: BrowserWindow | null = null;
private status: UpdateStatus = { status: 'idle' };
constructor() {
super();
// Configure auto-updater
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// Use logger
autoUpdater.logger = {
info: (msg: string) => console.log('[Updater]', msg),
warn: (msg: string) => console.warn('[Updater]', msg),
error: (msg: string) => console.error('[Updater]', msg),
debug: (msg: string) => console.debug('[Updater]', msg),
};
this.setupListeners();
}
/**
* Set the main window for sending update events
*/
setMainWindow(window: BrowserWindow): void {
this.mainWindow = window;
}
/**
* Get current update status
*/
getStatus(): UpdateStatus {
return this.status;
}
/**
* Setup auto-updater event listeners
*/
private setupListeners(): void {
autoUpdater.on('checking-for-update', () => {
this.updateStatus({ status: 'checking' });
this.emit('checking-for-update');
});
autoUpdater.on('update-available', (info: UpdateInfo) => {
this.updateStatus({ status: 'available', info });
this.emit('update-available', info);
});
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
this.updateStatus({ status: 'not-available', info });
this.emit('update-not-available', info);
});
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
this.updateStatus({ status: 'downloading', progress });
this.emit('download-progress', progress);
});
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
this.updateStatus({ status: 'downloaded', info: event });
this.emit('update-downloaded', event);
});
autoUpdater.on('error', (error: Error) => {
this.updateStatus({ status: 'error', error: error.message });
this.emit('error', error);
});
}
/**
* Update status and notify renderer
*/
private updateStatus(newStatus: Partial<UpdateStatus>): void {
this.status = { ...this.status, ...newStatus };
this.sendToRenderer('update:status-changed', this.status);
}
/**
* Send event to renderer process
*/
private sendToRenderer(channel: string, data: unknown): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, data);
}
}
/**
* Check for updates
*/
async checkForUpdates(): Promise<UpdateInfo | null> {
try {
const result = await autoUpdater.checkForUpdates();
return result?.updateInfo || null;
} catch (error) {
console.error('[Updater] Check for updates failed:', error);
return null;
}
}
/**
* Download available update
*/
async downloadUpdate(): Promise<void> {
try {
await autoUpdater.downloadUpdate();
} catch (error) {
console.error('[Updater] Download update failed:', error);
throw error;
}
}
/**
* Install update and restart app
*/
quitAndInstall(): void {
autoUpdater.quitAndInstall();
}
/**
* Set update channel (stable, beta, dev)
*/
setChannel(channel: 'stable' | 'beta' | 'dev'): void {
autoUpdater.channel = channel;
}
/**
* Set auto-download preference
*/
setAutoDownload(enable: boolean): void {
autoUpdater.autoDownload = enable;
}
/**
* Get current version
*/
getCurrentVersion(): string {
return app.getVersion();
}
}
/**
* Register IPC handlers for update operations
*/
export function registerUpdateHandlers(
updater: AppUpdater,
mainWindow: BrowserWindow
): void {
updater.setMainWindow(mainWindow);
// Get current update status
ipcMain.handle('update:status', () => {
return updater.getStatus();
});
// Get current version
ipcMain.handle('update:version', () => {
return updater.getCurrentVersion();
});
// Check for updates
ipcMain.handle('update:check', async () => {
try {
const info = await updater.checkForUpdates();
return { success: true, info };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Download update
ipcMain.handle('update:download', async () => {
try {
await updater.downloadUpdate();
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Install update and restart
ipcMain.handle('update:install', () => {
updater.quitAndInstall();
return { success: true };
});
// Set update channel
ipcMain.handle('update:setChannel', (_, channel: 'stable' | 'beta' | 'dev') => {
updater.setChannel(channel);
return { success: true };
});
// Set auto-download preference
ipcMain.handle('update:setAutoDownload', (_, enable: boolean) => {
updater.setAutoDownload(enable);
return { success: true };
});
// Forward update events to renderer
updater.on('checking-for-update', () => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:checking');
}
});
updater.on('update-available', (info) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:available', info);
}
});
updater.on('update-not-available', (info) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:not-available', info);
}
});
updater.on('download-progress', (progress) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:progress', progress);
}
});
updater.on('update-downloaded', (event) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:downloaded', event);
}
});
updater.on('error', (error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:error', error.message);
}
});
}
// Export singleton instance
export const appUpdater = new AppUpdater();

View File

@@ -43,10 +43,13 @@ const electronAPI = {
'settings:getAll',
'settings:reset',
// Update
'update:status',
'update:version',
'update:check',
'update:download',
'update:install',
'update:getStatus',
'update:setChannel',
'update:setAutoDownload',
// Env
'env:getConfig',
'env:setApiKey',
@@ -93,9 +96,13 @@ const electronAPI = {
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
'update:available',
'update:not-available',
'update:progress',
'update:downloaded',
'update:status',
'update:error',
'cron:updated',
];
@@ -128,9 +135,13 @@ const electronAPI = {
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
'update:available',
'update:not-available',
'update:progress',
'update:downloaded',
'update:status',
'update:error',
];
if (validChannels.includes(channel)) {

View File

@@ -0,0 +1,207 @@
/**
* Update Settings Component
* Displays update status and allows manual update checking/installation
*/
import { useEffect, useCallback } from 'react';
import { Download, RefreshCw, CheckCircle2, AlertCircle, Loader2, Rocket } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { useUpdateStore } from '@/stores/update';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export function UpdateSettings() {
const {
status,
currentVersion,
updateInfo,
progress,
error,
isInitialized,
init,
checkForUpdates,
downloadUpdate,
installUpdate,
clearError,
} = useUpdateStore();
// Initialize on mount
useEffect(() => {
init();
}, [init]);
const handleCheckForUpdates = useCallback(async () => {
clearError();
await checkForUpdates();
}, [checkForUpdates, clearError]);
const renderStatusIcon = () => {
switch (status) {
case 'checking':
return <Loader2 className="h-5 w-5 animate-spin text-blue-500" />;
case 'downloading':
return <Download className="h-5 w-5 text-blue-500 animate-pulse" />;
case 'available':
return <Download className="h-5 w-5 text-green-500" />;
case 'downloaded':
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'not-available':
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
default:
return <RefreshCw className="h-5 w-5 text-muted-foreground" />;
}
};
const renderStatusText = () => {
switch (status) {
case 'checking':
return 'Checking for updates...';
case 'downloading':
return 'Downloading update...';
case 'available':
return `Update available: v${updateInfo?.version}`;
case 'downloaded':
return `Ready to install: v${updateInfo?.version}`;
case 'error':
return error || 'Update check failed';
case 'not-available':
return 'You have the latest version';
default:
return 'Check for updates to get the latest features';
}
};
const renderAction = () => {
switch (status) {
case 'checking':
return (
<Button disabled variant="outline" size="sm">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Checking...
</Button>
);
case 'downloading':
return (
<Button disabled variant="outline" size="sm">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Downloading...
</Button>
);
case 'available':
return (
<Button onClick={downloadUpdate} size="sm">
<Download className="h-4 w-4 mr-2" />
Download Update
</Button>
);
case 'downloaded':
return (
<Button onClick={installUpdate} size="sm" variant="default">
<Rocket className="h-4 w-4 mr-2" />
Install & Restart
</Button>
);
case 'error':
return (
<Button onClick={handleCheckForUpdates} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
);
default:
return (
<Button onClick={handleCheckForUpdates} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Check for Updates
</Button>
);
}
};
if (!isInitialized) {
return (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading...</span>
</div>
);
}
return (
<div className="space-y-4">
{/* Current Version */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">Current Version</p>
<p className="text-2xl font-bold">v{currentVersion}</p>
</div>
{renderStatusIcon()}
</div>
{/* Status */}
<div className="flex items-center justify-between py-3 border-t border-b">
<p className="text-sm text-muted-foreground">{renderStatusText()}</p>
{renderAction()}
</div>
{/* Download Progress */}
{status === 'downloading' && progress && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>
{formatBytes(progress.transferred)} / {formatBytes(progress.total)}
</span>
<span>{formatBytes(progress.bytesPerSecond)}/s</span>
</div>
<Progress value={progress.percent} className="h-2" />
<p className="text-xs text-muted-foreground text-center">
{Math.round(progress.percent)}% complete
</p>
</div>
)}
{/* Update Info */}
{updateInfo && (status === 'available' || status === 'downloaded') && (
<div className="rounded-lg bg-muted p-4 space-y-2">
<div className="flex items-center justify-between">
<p className="font-medium">Version {updateInfo.version}</p>
{updateInfo.releaseDate && (
<p className="text-sm text-muted-foreground">
{new Date(updateInfo.releaseDate).toLocaleDateString()}
</p>
)}
</div>
{updateInfo.releaseNotes && (
<div className="text-sm text-muted-foreground prose prose-sm max-w-none">
<p className="font-medium text-foreground mb-1">What's New:</p>
<p className="whitespace-pre-wrap">{updateInfo.releaseNotes}</p>
</div>
)}
</div>
)}
{/* Error Details */}
{status === 'error' && error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/10 p-4 text-red-600 dark:text-red-400 text-sm">
<p className="font-medium mb-1">Error Details:</p>
<p>{error}</p>
</div>
)}
{/* Help Text */}
<p className="text-xs text-muted-foreground">
Updates are downloaded in the background and installed when you restart the app.
</p>
</div>
);
}
export default UpdateSettings;

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -2,16 +2,15 @@
* Settings Page
* Application configuration
*/
import { useState, useEffect } from 'react';
import {
Sun,
Moon,
Monitor,
RefreshCw,
Loader2,
Terminal,
ExternalLink,
Key,
Download,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -21,7 +20,9 @@ import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { useSettingsStore } from '@/stores/settings';
import { useGatewayStore } from '@/stores/gateway';
import { useUpdateStore } from '@/stores/update';
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
import { UpdateSettings } from '@/components/settings/UpdateSettings';
export function Settings() {
const {
@@ -37,26 +38,7 @@ export function Settings() {
} = useSettingsStore();
const { status: gatewayStatus, restart: restartGateway } = useGatewayStore();
const [appVersion, setAppVersion] = useState('0.1.0');
const [checkingUpdate, setCheckingUpdate] = useState(false);
// Get app version
useEffect(() => {
window.electron.ipcRenderer.invoke('app:version').then((version) => {
setAppVersion(version as string);
});
}, []);
// Check for updates
const handleCheckUpdate = async () => {
setCheckingUpdate(true);
try {
await window.electron.ipcRenderer.invoke('update:check');
} finally {
setCheckingUpdate(false);
}
};
const currentVersion = useUpdateStore((state) => state.currentVersion);
// Open developer console
const openDevConsole = () => {
@@ -178,32 +160,14 @@ export function Settings() {
{/* Updates */}
<Card>
<CardHeader>
<CardTitle>Updates</CardTitle>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Updates
</CardTitle>
<CardDescription>Keep ClawX up to date</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg border">
<div>
<p className="font-medium">ClawX</p>
<p className="text-sm text-muted-foreground">
Version {appVersion}
</p>
</div>
<Button
variant="outline"
onClick={handleCheckUpdate}
disabled={checkingUpdate}
>
{checkingUpdate ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Checking...
</>
) : (
'Check for Updates'
)}
</Button>
</div>
<UpdateSettings />
<Separator />
@@ -271,7 +235,7 @@ export function Settings() {
<strong>ClawX</strong> - Graphical AI Assistant
</p>
<p>Based on OpenClaw</p>
<p>Version {appVersion}</p>
<p>Version {currentVersion}</p>
<div className="flex gap-4 pt-2">
<Button
variant="link"

184
src/stores/update.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* Update State Store
* Manages application update state
*/
import { create } from 'zustand';
export interface UpdateInfo {
version: string;
releaseDate?: string;
releaseNotes?: string | null;
}
export interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'downloaded'
| 'error';
interface UpdateState {
status: UpdateStatus;
currentVersion: string;
updateInfo: UpdateInfo | null;
progress: ProgressInfo | null;
error: string | null;
isInitialized: boolean;
// Actions
init: () => Promise<void>;
checkForUpdates: () => Promise<void>;
downloadUpdate: () => Promise<void>;
installUpdate: () => void;
setChannel: (channel: 'stable' | 'beta' | 'dev') => Promise<void>;
setAutoDownload: (enable: boolean) => Promise<void>;
clearError: () => void;
}
export const useUpdateStore = create<UpdateState>((set, get) => ({
status: 'idle',
currentVersion: '0.0.0',
updateInfo: null,
progress: null,
error: null,
isInitialized: false,
init: async () => {
if (get().isInitialized) return;
// Get current version
try {
const version = await window.electron.ipcRenderer.invoke('update:version');
set({ currentVersion: version as string });
} catch (error) {
console.error('Failed to get version:', error);
}
// Get current status
try {
const status = await window.electron.ipcRenderer.invoke('update:status') as {
status: UpdateStatus;
info?: UpdateInfo;
progress?: ProgressInfo;
error?: string;
};
set({
status: status.status,
updateInfo: status.info || null,
progress: status.progress || null,
error: status.error || null,
});
} catch (error) {
console.error('Failed to get update status:', error);
}
// Listen for update events
window.electron.ipcRenderer.on('update:status-changed', (data) => {
const status = data as {
status: UpdateStatus;
info?: UpdateInfo;
progress?: ProgressInfo;
error?: string;
};
set({
status: status.status,
updateInfo: status.info || null,
progress: status.progress || null,
error: status.error || null,
});
});
window.electron.ipcRenderer.on('update:checking', () => {
set({ status: 'checking', error: null });
});
window.electron.ipcRenderer.on('update:available', (info) => {
set({ status: 'available', updateInfo: info as UpdateInfo });
});
window.electron.ipcRenderer.on('update:not-available', () => {
set({ status: 'not-available' });
});
window.electron.ipcRenderer.on('update:progress', (progress) => {
set({ status: 'downloading', progress: progress as ProgressInfo });
});
window.electron.ipcRenderer.on('update:downloaded', (info) => {
set({ status: 'downloaded', updateInfo: info as UpdateInfo, progress: null });
});
window.electron.ipcRenderer.on('update:error', (error) => {
set({ status: 'error', error: error as string, progress: null });
});
set({ isInitialized: true });
},
checkForUpdates: async () => {
set({ status: 'checking', error: null });
try {
const result = await window.electron.ipcRenderer.invoke('update:check') as {
success: boolean;
info?: UpdateInfo;
error?: string;
};
if (!result.success) {
set({ status: 'error', error: result.error || 'Failed to check for updates' });
}
} catch (error) {
set({ status: 'error', error: String(error) });
}
},
downloadUpdate: async () => {
set({ status: 'downloading', error: null });
try {
const result = await window.electron.ipcRenderer.invoke('update:download') as {
success: boolean;
error?: string;
};
if (!result.success) {
set({ status: 'error', error: result.error || 'Failed to download update' });
}
} catch (error) {
set({ status: 'error', error: String(error) });
}
},
installUpdate: () => {
window.electron.ipcRenderer.invoke('update:install');
},
setChannel: async (channel) => {
try {
await window.electron.ipcRenderer.invoke('update:setChannel', channel);
} catch (error) {
console.error('Failed to set update channel:', error);
}
},
setAutoDownload: async (enable) => {
try {
await window.electron.ipcRenderer.invoke('update:setAutoDownload', enable);
} catch (error) {
console.error('Failed to set auto-download:', error);
}
},
clearError: () => set({ error: null, status: 'idle' }),
}));