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:
jack
2025-06-12 20:25:44 +02:00
committed by GitHub
parent ff80c2f44a
commit 37812fc76a
15 changed files with 1264 additions and 32 deletions
@@ -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>
);
}
+1 -1
View File
@@ -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',