feat(desktop): Add auto-update functionality to Goose desktop app (#2852)
Co-authored-by: jack <jack@deck.local> Co-authored-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
||||
export type SettingsViewOptions = {
|
||||
deepLinkConfig?: ExtensionConfig;
|
||||
showEnvVars?: boolean;
|
||||
section?: string;
|
||||
};
|
||||
|
||||
export default function SettingsView({
|
||||
@@ -55,7 +56,7 @@ export default function SettingsView({
|
||||
{/* Tool Selection Strategy */}
|
||||
<ToolSelectionStrategySection setView={setView} />
|
||||
{/* App Settings */}
|
||||
<AppSettingsSection />
|
||||
<AppSettingsSection scrollToSection={viewOptions.section} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Switch } from '../../ui/switch';
|
||||
import UpdateSection from './UpdateSection';
|
||||
|
||||
export default function AppSettingsSection() {
|
||||
interface AppSettingsSectionProps {
|
||||
scrollToSection?: string;
|
||||
}
|
||||
|
||||
export default function AppSettingsSection({ scrollToSection }: AppSettingsSectionProps) {
|
||||
const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true);
|
||||
const [dockIconEnabled, setDockIconEnabled] = useState(true);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false);
|
||||
const updateSectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check if running on macOS
|
||||
useEffect(() => {
|
||||
setIsMacOS(window.electron.platform === 'darwin');
|
||||
}, []);
|
||||
|
||||
// Handle scrolling to update section
|
||||
useEffect(() => {
|
||||
if (scrollToSection === 'update' && updateSectionRef.current) {
|
||||
// Use a timeout to ensure the DOM is ready
|
||||
setTimeout(() => {
|
||||
updateSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 100);
|
||||
}
|
||||
}, [scrollToSection]);
|
||||
|
||||
// Load menu bar and dock icon states
|
||||
useEffect(() => {
|
||||
window.electron.getMenuBarIconState().then((enabled) => {
|
||||
@@ -106,6 +122,11 @@ export default function AppSettingsSection() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update Section */}
|
||||
<div ref={updateSectionRef} className="mt-8 pt-8 border-t border-gray-200">
|
||||
<UpdateSection />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Loader2, Download, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
type UpdateStatus =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'downloading'
|
||||
| 'installing'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'ready';
|
||||
|
||||
interface UpdateInfo {
|
||||
currentVersion: string;
|
||||
latestVersion?: string;
|
||||
isUpdateAvailable?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UpdateEventData {
|
||||
version?: string;
|
||||
percent?: number;
|
||||
}
|
||||
|
||||
export default function UpdateSection() {
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo>({
|
||||
currentVersion: '',
|
||||
});
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Get current version on mount
|
||||
const currentVersion = window.electron.getVersion();
|
||||
setUpdateInfo((prev) => ({ ...prev, currentVersion }));
|
||||
|
||||
// Check if there's already an update state from the auto-check
|
||||
window.electron.getUpdateState().then((state) => {
|
||||
if (state) {
|
||||
console.log('Found existing update state:', state);
|
||||
setUpdateInfo((prev) => ({
|
||||
...prev,
|
||||
isUpdateAvailable: state.updateAvailable,
|
||||
latestVersion: state.latestVersion,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for updater events
|
||||
window.electron.onUpdaterEvent((event) => {
|
||||
console.log('Updater event:', event);
|
||||
|
||||
switch (event.event) {
|
||||
case 'checking-for-update':
|
||||
setUpdateStatus('checking');
|
||||
break;
|
||||
|
||||
case 'update-available':
|
||||
setUpdateStatus('idle');
|
||||
setUpdateInfo((prev) => ({
|
||||
...prev,
|
||||
latestVersion: (event.data as UpdateEventData)?.version,
|
||||
isUpdateAvailable: true,
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'update-not-available':
|
||||
setUpdateStatus('idle');
|
||||
setUpdateInfo((prev) => ({
|
||||
...prev,
|
||||
isUpdateAvailable: false,
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'download-progress':
|
||||
setUpdateStatus('downloading');
|
||||
setProgress((event.data as UpdateEventData)?.percent || 0);
|
||||
break;
|
||||
|
||||
case 'update-downloaded':
|
||||
setUpdateStatus('ready');
|
||||
setProgress(100);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setUpdateStatus('error');
|
||||
setUpdateInfo((prev) => ({
|
||||
...prev,
|
||||
error: String(event.data || 'An error occurred'),
|
||||
}));
|
||||
setTimeout(() => setUpdateStatus('idle'), 5000);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setUpdateStatus('checking');
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const result = await window.electron.checkForUpdates();
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
// If we successfully checked and no update is available, show success
|
||||
if (!result.error && updateInfo.isUpdateAvailable === false) {
|
||||
setUpdateStatus('success');
|
||||
setTimeout(() => setUpdateStatus('idle'), 3000);
|
||||
}
|
||||
// The actual status will be handled by the updater events
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error);
|
||||
setUpdateInfo((prev) => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||
}));
|
||||
setUpdateStatus('error');
|
||||
setTimeout(() => setUpdateStatus('idle'), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAndInstallUpdate = async () => {
|
||||
setUpdateStatus('downloading');
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const result = await window.electron.downloadUpdate();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to download update');
|
||||
}
|
||||
|
||||
// The download progress and completion will be handled by updater events
|
||||
} catch (error) {
|
||||
console.error('Error downloading update:', error);
|
||||
setUpdateInfo((prev) => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : 'Failed to download update',
|
||||
}));
|
||||
setUpdateStatus('error');
|
||||
setTimeout(() => setUpdateStatus('idle'), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const installUpdate = () => {
|
||||
setUpdateStatus('installing');
|
||||
// This will quit the app and install the update
|
||||
window.electron.installUpdate();
|
||||
};
|
||||
|
||||
const getStatusMessage = () => {
|
||||
switch (updateStatus) {
|
||||
case 'checking':
|
||||
return 'Checking for updates...';
|
||||
case 'downloading':
|
||||
return `Downloading update... ${Math.round(progress)}%`;
|
||||
case 'installing':
|
||||
return 'Installing update...';
|
||||
case 'ready':
|
||||
return 'Update downloaded and ready to install!';
|
||||
case 'success':
|
||||
return updateInfo.isUpdateAvailable === false
|
||||
? 'You are running the latest version!'
|
||||
: 'Update available!';
|
||||
case 'error':
|
||||
return updateInfo.error || 'An error occurred';
|
||||
default:
|
||||
if (updateInfo.isUpdateAvailable) {
|
||||
return `Version ${updateInfo.latestVersion} is available`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (updateStatus) {
|
||||
case 'checking':
|
||||
case 'downloading':
|
||||
case 'installing':
|
||||
return <Loader2 className="w-4 h-4 animate-spin" />;
|
||||
case 'success':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
case 'ready':
|
||||
return <CheckCircle className="w-4 h-4 text-blue-500" />;
|
||||
default:
|
||||
return updateInfo.isUpdateAvailable ? <Download className="w-4 h-4" /> : null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-medium text-textStandard">Updates</h2>
|
||||
</div>
|
||||
<div className="pb-8">
|
||||
<p className="text-sm text-textStandard mb-6">
|
||||
Current version: {updateInfo.currentVersion || 'Loading...'}
|
||||
{updateInfo.latestVersion && updateInfo.isUpdateAvailable && (
|
||||
<span className="text-textSubtle"> → {updateInfo.latestVersion} available</span>
|
||||
)}
|
||||
{updateInfo.currentVersion && updateInfo.isUpdateAvailable === false && ' (up to date)'}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={checkForUpdates}
|
||||
disabled={updateStatus !== 'idle' && updateStatus !== 'error'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Check for Updates
|
||||
</Button>
|
||||
|
||||
{updateInfo.isUpdateAvailable && updateStatus === 'idle' && (
|
||||
<Button
|
||||
onClick={downloadAndInstallUpdate}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
Download Update
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{updateStatus === 'ready' && (
|
||||
<Button onClick={installUpdate} variant="default" size="sm" className="text-xs">
|
||||
Install & Restart
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{getStatusMessage() && (
|
||||
<div className="flex items-center gap-2 text-xs text-textSubtle">
|
||||
{getStatusIcon()}
|
||||
<span>{getStatusMessage()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateStatus === 'downloading' && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update information */}
|
||||
{updateInfo.isUpdateAvailable && (
|
||||
<div className="text-xs text-textSubtle mt-4 space-y-1">
|
||||
<p>Update will be downloaded and automatically extracted to your Downloads folder.</p>
|
||||
<p className="text-xs text-amber-600">
|
||||
After download, move the Goose app to /Applications to complete the update.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const buttonVariants = cva(
|
||||
default: 'bg-gray-800 text-white rounded-full px-6 py-2 hover:bg-gray-700',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-gray-300 bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
'border border-gray-300 dark:border-gray-600 bg-background text-textStandard hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
|
||||
Reference in New Issue
Block a user