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:
207
src/components/settings/UpdateSettings.tsx
Normal file
207
src/components/settings/UpdateSettings.tsx
Normal 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;
|
||||
26
src/components/ui/progress.tsx
Normal file
26
src/components/ui/progress.tsx
Normal 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 }
|
||||
@@ -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
184
src/stores/update.ts
Normal 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' }),
|
||||
}));
|
||||
Reference in New Issue
Block a user