Feat/automatic update installation (#5345)

Signed-off-by: AdemolaAri <ademola.ari@gmail.com>
This commit is contained in:
Ademola Arigbabuwo
2025-12-01 14:50:41 -05:00
committed by GitHub
parent 8e9b6c9bc6
commit c3c71a2ff4
5 changed files with 224 additions and 66 deletions
+9 -1
View File
@@ -170,11 +170,19 @@ const PairRouteWrapper = ({
const SettingsRoute = () => {
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const setView = useNavigation();
// Get viewOptions from location.state or history.state
// Get viewOptions from location.state, history.state, or URL search params
const viewOptions =
(location.state as SettingsViewOptions) || (window.history.state as SettingsViewOptions) || {};
// If section is provided via URL search params, add it to viewOptions
const sectionFromUrl = searchParams.get('section');
if (sectionFromUrl) {
viewOptions.section = sectionFromUrl;
}
return <SettingsView onClose={() => navigate('/')} setView={setView} viewOptions={viewOptions} />;
};
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { Button } from '../../ui/button';
import { Loader2, Download, CheckCircle, AlertCircle } from 'lucide-react';
@@ -29,6 +29,9 @@ export default function UpdateSection() {
currentVersion: '',
});
const [progress, setProgress] = useState<number>(0);
const [isUsingGitHubFallback, setIsUsingGitHubFallback] = useState<boolean>(false);
const progressTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const lastProgressRef = React.useRef<number>(0); // Track last progress to prevent backward jumps
useEffect(() => {
// Get current version on mount
@@ -47,6 +50,11 @@ export default function UpdateSection() {
}
});
// Check if using GitHub fallback
window.electron.isUsingGitHubFallback().then((isGitHub) => {
setIsUsingGitHubFallback(isGitHub);
});
// Listen for updater events
window.electron.onUpdaterEvent((event) => {
console.log('Updater event:', event);
@@ -63,6 +71,10 @@ export default function UpdateSection() {
latestVersion: (event.data as UpdateEventData)?.version,
isUpdateAvailable: true,
}));
// Check if GitHub fallback is being used
window.electron.isUsingGitHubFallback().then((isGitHub) => {
setIsUsingGitHubFallback(isGitHub);
});
break;
case 'update-not-available':
@@ -73,10 +85,29 @@ export default function UpdateSection() {
}));
break;
case 'download-progress':
case 'download-progress': {
setUpdateStatus('downloading');
setProgress((event.data as UpdateEventData)?.percent || 0);
// Get the new progress value (ensure it's a valid number)
const rawPercent = (event.data as UpdateEventData)?.percent;
const newProgress = typeof rawPercent === 'number' ? Math.round(rawPercent) : 0;
// Only update if progress increased (prevents backward jumps from out-of-order events)
if (newProgress > lastProgressRef.current) {
lastProgressRef.current = newProgress;
// Cancel any pending update
if (progressTimeoutRef.current) {
clearTimeout(progressTimeoutRef.current);
}
// Use a small delay to batch rapid updates
progressTimeoutRef.current = setTimeout(() => {
setProgress(newProgress);
}, 50); // 50ms delay for smoother batching
}
break;
}
case 'update-downloaded':
setUpdateStatus('ready');
@@ -93,11 +124,19 @@ export default function UpdateSection() {
break;
}
});
// Cleanup timeout on unmount
return () => {
if (progressTimeoutRef.current) {
clearTimeout(progressTimeoutRef.current);
}
};
}, []);
const checkForUpdates = async () => {
setUpdateStatus('checking');
setProgress(0);
lastProgressRef.current = 0; // Reset progress tracking for new download
try {
const result = await window.electron.checkForUpdates();
@@ -123,29 +162,6 @@ export default function UpdateSection() {
}
};
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 = () => {
window.electron.installUpdate();
};
@@ -216,13 +232,6 @@ export default function UpdateSection() {
Check for Updates
</Button>
{updateInfo.isUpdateAvailable && updateStatus === 'idle' && (
<Button onClick={downloadAndInstallUpdate} variant="secondary" size="sm">
<Download className="w-3 h-3 mr-1" />
Download Update
</Button>
)}
{updateStatus === 'ready' && (
<Button onClick={installUpdate} variant="default" size="sm">
Install & Restart
@@ -238,21 +247,57 @@ export default function UpdateSection() {
)}
{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 className="w-full mt-2">
<div className="flex justify-between text-xs text-text-muted mb-1">
<span>Downloading update...</span>
<span>{progress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
className="bg-blue-500 h-2 rounded-full transition-[width] duration-150 ease-out"
style={{ width: `${Math.max(progress, 0)}%`, minWidth: progress > 0 ? '8px' : '0' }}
/>
</div>
</div>
)}
{/* Update information */}
{updateInfo.isUpdateAvailable && (
{updateInfo.isUpdateAvailable && updateStatus === 'idle' && (
<div className="text-xs text-text-muted mt-4 space-y-1">
<p>Update will be downloaded to your Downloads folder.</p>
<p className="text-xs text-amber-600">
Note: After downloading, you'll need to close the app and manually install the update.
</p>
<p>Update will be downloaded automatically in the background.</p>
{isUsingGitHubFallback ? (
<p className="text-xs text-amber-600">
After download, you'll need to manually install the update.
</p>
) : (
<p className="text-xs text-green-600">
The update will be installed automatically when you quit the app.
</p>
)}
</div>
)}
{updateStatus === 'ready' && (
<div className="text-xs text-text-muted mt-4 space-y-1">
{isUsingGitHubFallback ? (
<>
<p className="text-xs text-green-600">
Update is ready! Click "Install & Restart" for installation instructions.
</p>
<p className="text-xs text-text-muted">
Manual installation required for this update method.
</p>
</>
) : (
<>
<p className="text-xs text-green-600">
Update is ready! It will be installed when you quit Goose.
</p>
<p className="text-xs text-text-muted">
Or click "Install & Restart" to update now.
</p>
</>
)}
</div>
)}
</div>
+4
View File
@@ -111,6 +111,7 @@ type ElectronAPI = {
restartApp: () => void;
onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => void;
getUpdateState: () => Promise<{ updateAvailable: boolean; latestVersion?: string } | null>;
isUsingGitHubFallback: () => Promise<boolean>;
// Recipe warning functions
closeWindow: () => void;
hasAcceptedRecipeBefore: (recipe: Recipe) => Promise<boolean>;
@@ -241,6 +242,9 @@ const electronAPI: ElectronAPI = {
getUpdateState: (): Promise<{ updateAvailable: boolean; latestVersion?: string } | null> => {
return ipcRenderer.invoke('get-update-state');
},
isUsingGitHubFallback: (): Promise<boolean> => {
return ipcRenderer.invoke('is-using-github-fallback');
},
closeWindow: () => ipcRenderer.send('close-window'),
hasAcceptedRecipeBefore: (recipe: Recipe) =>
ipcRenderer.invoke('has-accepted-recipe-before', recipe),
+108 -19
View File
@@ -9,6 +9,7 @@ import {
dialog,
Menu,
MenuItemConstructorOptions,
Notification,
} from 'electron';
import * as path from 'path';
import * as fs from 'fs/promises';
@@ -30,6 +31,9 @@ let githubUpdateInfo: {
// Store update state
let lastUpdateState: { updateAvailable: boolean; latestVersion?: string } | null = null;
// Track last reported progress to prevent backward jumps
let lastReportedProgress = 0;
// Track if IPC handlers have been registered
let ipcUpdateHandlersRegistered = false;
@@ -47,9 +51,10 @@ export function registerUpdateIpcHandlers() {
try {
log.info('Manual check for updates requested');
// Reset fallback flag
// Reset state for new update check
isUsingGitHubFallback = false;
githubUpdateInfo = {};
lastReportedProgress = 0; // Reset progress tracking
// Ensure auto-updater is properly initialized
if (!autoUpdater.currentVersion) {
@@ -116,6 +121,10 @@ export function registerUpdateIpcHandlers() {
lastUpdateState = { updateAvailable: true, latestVersion: result.latestVersion };
updateTrayIcon(true);
sendStatusToWindow('update-available', { version: result.latestVersion });
// Auto-download for GitHub fallback (matching autoDownload behavior)
log.info('Auto-downloading update via GitHub fallback...');
await githubAutoDownload(result.downloadUrl!, result.latestVersion!, 'manual check');
} else {
updateAvailable = false;
lastUpdateState = { updateAvailable: false };
@@ -149,12 +158,17 @@ export function registerUpdateIpcHandlers() {
try {
if (isUsingGitHubFallback && githubUpdateInfo.downloadUrl && githubUpdateInfo.latestVersion) {
log.info('Using GitHub fallback for download...');
lastReportedProgress = 0; // Reset progress tracking
const result = await githubUpdater.downloadUpdate(
githubUpdateInfo.downloadUrl,
githubUpdateInfo.latestVersion,
(percent) => {
sendStatusToWindow('download-progress', { percent });
// Only send if progress increased (monotonic)
if (percent > lastReportedProgress) {
lastReportedProgress = percent;
sendStatusToWindow('download-progress', { percent });
}
}
);
@@ -200,25 +214,26 @@ export function registerUpdateIpcHandlers() {
throw new Error('Update file not found. Please download the update first.');
}
// Show dialog to inform user about manual installation
// Improved dialog with clearer instructions
const dialogResult = (await dialog.showMessageBox({
type: 'info',
title: 'Update Downloaded',
message: 'The update has been downloaded to your Downloads folder.',
detail: `Please extract the zip file and move the Goose app to your Applications folder to complete the update.`,
buttons: ['Open Downloads', 'Cancel'],
title: 'Update Ready to Install',
message: `Version ${githubUpdateInfo.latestVersion} is ready to install.`,
detail: `The update has been downloaded and extracted. To complete the installation:\n\n1. Click "Open Folder" to view the new Goose.app\n2. Quit Goose (this app will close)\n3. Drag the new Goose.app to your Applications folder\n4. Replace the existing app when prompted\n\nThe update will be available the next time you launch Goose.`,
buttons: ['Open Folder & Quit', 'Open Folder Only', 'Cancel'],
defaultId: 0,
cancelId: 1,
cancelId: 2,
})) as unknown as { response: number };
if (dialogResult.response === 0) {
// Open the extracted folder or show the zip file
// Open folder and quit app for easy replacement
shell.showItemInFolder(updatePath);
// Optionally quit the app so user can replace it
setTimeout(() => {
app.quit();
}, 1000);
}, 1500); // Give user time to see the folder open
} else if (dialogResult.response === 1) {
// Just open folder, don't quit
shell.showItemInFolder(updatePath);
}
} catch (error) {
log.error('Error installing GitHub update:', error);
@@ -237,6 +252,10 @@ export function registerUpdateIpcHandlers() {
ipcMain.handle('get-update-state', () => {
return lastUpdateState;
});
ipcMain.handle('is-using-github-fallback', () => {
return isUsingGitHubFallback;
});
}
// Configure auto-updater
@@ -273,7 +292,7 @@ export function setupAutoUpdater(tray?: Tray) {
}
// Configure auto-updater settings
autoUpdater.autoDownload = false; // We'll trigger downloads manually
autoUpdater.autoDownload = true; // Automatically download updates when available
autoUpdater.autoInstallOnAppQuit = true;
// Enable updates in development mode for testing
@@ -330,7 +349,7 @@ export function setupAutoUpdater(tray?: Tray) {
githubUpdater
.checkForUpdates()
.then((result) => {
.then(async (result) => {
if (result.error) {
sendStatusToWindow('error', result.error);
} else if (result.updateAvailable) {
@@ -345,6 +364,10 @@ export function setupAutoUpdater(tray?: Tray) {
lastUpdateState = { updateAvailable: true, latestVersion: result.latestVersion };
updateTrayIcon(true);
sendStatusToWindow('update-available', { version: result.latestVersion });
// Auto-download for GitHub fallback (matching autoDownload behavior)
log.info('Auto-downloading update via GitHub fallback on startup...');
await githubAutoDownload(result.downloadUrl!, result.latestVersion!, 'on startup');
} else {
updateAvailable = false;
lastUpdateState = { updateAvailable: false };
@@ -365,6 +388,7 @@ export function setupAutoUpdater(tray?: Tray) {
autoUpdater.on('checking-for-update', () => {
log.info('Auto-updater: Checking for update...');
log.info(`Auto-updater: Feed URL during check: ${autoUpdater.getFeedURL()}`);
lastReportedProgress = 0; // Reset progress tracking for new check
sendStatusToWindow('checking-for-update');
});
@@ -421,6 +445,10 @@ export function setupAutoUpdater(tray?: Tray) {
updateAvailable = true;
updateTrayIcon(true);
sendStatusToWindow('update-available', { version: result.latestVersion });
// Auto-download for GitHub fallback (matching autoDownload behavior)
log.info('Auto-downloading update via GitHub fallback after error...');
await githubAutoDownload(result.downloadUrl!, result.latestVersion!, 'after error');
} else {
updateAvailable = false;
updateTrayIcon(false);
@@ -441,16 +469,37 @@ export function setupAutoUpdater(tray?: Tray) {
});
autoUpdater.on('download-progress', (progressObj) => {
let log_message = 'Download speed: ' + progressObj.bytesPerSecond;
log_message = log_message + ' - Downloaded ' + progressObj.percent + '%';
log_message = log_message + ' (' + progressObj.transferred + '/' + progressObj.total + ')';
log.info(log_message);
sendStatusToWindow('download-progress', progressObj);
const roundedPercent = Math.round(progressObj.percent);
// Only send progress if it increased (prevents backward jumps)
if (roundedPercent > lastReportedProgress) {
lastReportedProgress = roundedPercent;
const log_message = `Download: ${roundedPercent}% (${progressObj.transferred}/${progressObj.total}) @ ${Math.round(progressObj.bytesPerSecond / 1024)} KB/s`;
log.info(log_message);
sendStatusToWindow('download-progress', {
...progressObj,
percent: roundedPercent,
});
}
});
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
log.info('Update downloaded:', info);
sendStatusToWindow('update-downloaded', info);
// Show native notification
const notification = new Notification({
title: 'Update Ready',
body: `Version ${info.version} will be installed when you quit Goose. Click to install now.`,
});
notification.show();
// Optional: Add click handler to install immediately
notification.on('click', () => {
autoUpdater.quitAndInstall(false, true);
});
});
}
@@ -466,6 +515,46 @@ function sendStatusToWindow(event: string, data?: unknown) {
});
}
// centralize GitHub fallback auto-download logic.
async function githubAutoDownload(
downloadUrl: string,
latestVersion: string,
contextLabel = ''
): Promise<void> {
// Reset progress tracking for new download
lastReportedProgress = 0;
try {
const downloadResult = await githubUpdater.downloadUpdate(
downloadUrl,
latestVersion,
(percent) => {
// Only send if progress increased (monotonic)
if (percent > lastReportedProgress) {
lastReportedProgress = percent;
sendStatusToWindow('download-progress', { percent });
}
}
);
if (downloadResult.success && downloadResult.downloadPath) {
githubUpdateInfo.downloadPath = downloadResult.downloadPath;
githubUpdateInfo.extractedPath = downloadResult.extractedPath;
sendStatusToWindow('update-downloaded', { version: latestVersion });
} else {
log.error(
`GitHub auto-download failed${contextLabel ? ` (${contextLabel})` : ''}:`,
downloadResult.error
);
}
} catch (downloadError) {
log.error(
`Error during GitHub auto-download${contextLabel ? ` (${contextLabel})` : ''}:`,
downloadError
);
}
}
function updateTrayIcon(hasUpdate: boolean) {
if (!trayRef) return;
+15 -3
View File
@@ -106,7 +106,7 @@ export class GitHubUpdater {
log.info(`GitHubUpdater: Looking for asset named: ${assetName}`);
log.info(`GitHubUpdater: Available assets: ${release.assets.map((a) => a.name).join(', ')}`);
const asset = release.assets.find((a) => a.name === assetName);
const asset = release.assets.find((a) => a.name.toLowerCase() === assetName.toLowerCase()); // keeping comparison to lower case becasue Goose vs goose
if (asset) {
downloadUrl = asset.browser_download_url;
log.info(`GitHubUpdater: Found matching asset: ${asset.name} (${asset.size} bytes)`);
@@ -115,6 +115,12 @@ export class GitHubUpdater {
log.warn(`GitHubUpdater: No matching asset found for ${assetName}`);
}
if (!downloadUrl) {
throw new Error(
`Update Available but no download URL found for platform: ${platform}, arch: ${arch}`
);
}
return {
updateAvailable: true,
latestVersion,
@@ -159,6 +165,7 @@ export class GitHubUpdater {
if (!response.body) {
throw new Error('Response body is null');
}
let lastReportedPercent = -1; // Track last reported percentage to throttle updates
// Read the response stream
const reader = response.body.getReader();
@@ -172,10 +179,15 @@ export class GitHubUpdater {
chunks.push(value);
downloadedSize += value.length;
// Report progress
// Report progress - only when percentage changes by at least 1%
if (totalSize > 0 && onProgress) {
const percent = Math.round((downloadedSize / totalSize) * 100);
onProgress(percent);
// Only report if percent changed (throttles from hundreds/sec to ~100 total)
if (percent !== lastReportedPercent) {
onProgress(percent);
lastReportedPercent = percent;
}
}
}