From c0680049e984c80bf72cc4d8ef0f4590eb764fe0 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 10 Jun 2025 13:00:50 +0200 Subject: [PATCH 01/10] feat(ui): add update functionality to desktop app settings - add UpdateSection component with check for updates and install functionality - add IPC handlers for executing update scripts and restarting the app - integrate update section into AppSettingsSection - support checking github releases for latest version - display progress during download and installation - prompt user to restart after successful update --- .../settings/app/AppSettingsSection.tsx | 6 + .../components/settings/app/UpdateSection.tsx | 222 ++++++++++++++++++ ui/desktop/src/main.ts | 54 +++++ ui/desktop/src/preload.ts | 18 ++ 4 files changed, 300 insertions(+) create mode 100644 ui/desktop/src/components/settings/app/UpdateSection.tsx diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 317b9e2b29c2..69182af648a3 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { Switch } from '../../ui/switch'; +import UpdateSection from './UpdateSection'; export default function AppSettingsSection() { const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true); @@ -106,6 +107,11 @@ export default function AppSettingsSection() { )} + + {/* Update Section */} +
+ +
); diff --git a/ui/desktop/src/components/settings/app/UpdateSection.tsx b/ui/desktop/src/components/settings/app/UpdateSection.tsx new file mode 100644 index 000000000000..012a02cee53f --- /dev/null +++ b/ui/desktop/src/components/settings/app/UpdateSection.tsx @@ -0,0 +1,222 @@ +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'; + +interface UpdateInfo { + currentVersion: string; + latestVersion?: string; + isUpdateAvailable?: boolean; + error?: string; +} + +export default function UpdateSection() { + const [updateStatus, setUpdateStatus] = useState('idle'); + const [updateInfo, setUpdateInfo] = useState({ + currentVersion: '', + }); + const [progress, setProgress] = useState(0); + + useEffect(() => { + // Get current version on mount + const currentVersion = window.electron.getVersion(); + setUpdateInfo((prev) => ({ ...prev, currentVersion })); + }, []); + + const checkForUpdates = async () => { + setUpdateStatus('checking'); + setProgress(0); + + try { + // Check for updates by fetching release information + const response = await fetch('https://api.github.com/repos/block/goose/releases/latest'); + + if (!response.ok) { + throw new Error('Failed to check for updates'); + } + + const data = await response.json(); + const latestVersion = data.tag_name?.replace('v', '') || data.name; + + // Compare versions + const isUpdateAvailable = latestVersion !== updateInfo.currentVersion; + + setUpdateInfo((prev) => ({ + ...prev, + latestVersion, + isUpdateAvailable, + })); + + if (!isUpdateAvailable) { + setUpdateStatus('success'); + setTimeout(() => setUpdateStatus('idle'), 3000); + } else { + setUpdateStatus('idle'); + } + } 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 { + // Simulate progress for better UX + const progressInterval = setInterval(() => { + setProgress((prev) => { + if (prev >= 90) { + clearInterval(progressInterval); + return prev; + } + return prev + Math.random() * 10; + }); + }, 300); + + // Download the update script and execute it + const scriptResponse = await fetch( + 'https://github.com/block/goose/releases/download/stable/download_cli.sh' + ); + + if (!scriptResponse.ok) { + throw new Error('Failed to download update script'); + } + + const scriptContent = await scriptResponse.text(); + + // Clear progress interval + clearInterval(progressInterval); + setProgress(100); + setUpdateStatus('installing'); + + // Execute the update through electron IPC + const result = await window.electron.executeUpdate(scriptContent); + + if (result.success) { + setUpdateStatus('success'); + setUpdateInfo((prev) => ({ + ...prev, + currentVersion: prev.latestVersion || prev.currentVersion, + isUpdateAvailable: false, + })); + + // Prompt to restart the app + setTimeout(() => { + if ( + window.confirm('Update installed successfully! Would you like to restart Goose now?') + ) { + window.electron.restartApp(); + } + }, 1000); + } else { + throw new Error(result.error || 'Failed to install update'); + } + } catch (error) { + console.error('Error downloading/installing update:', error); + setUpdateInfo((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to install update', + })); + setUpdateStatus('error'); + setTimeout(() => setUpdateStatus('idle'), 5000); + } + }; + + const getStatusMessage = () => { + switch (updateStatus) { + case 'checking': + return 'Checking for updates...'; + case 'downloading': + return `Downloading update... ${Math.round(progress)}%`; + case 'installing': + return 'Installing update...'; + case 'success': + return updateInfo.isUpdateAvailable === false + ? 'You are running the latest version!' + : 'Update installed successfully!'; + 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 ; + case 'success': + return ; + case 'error': + return ; + default: + return updateInfo.isUpdateAvailable ? : null; + } + }; + + return ( +
+
+

Updates

+

+ Current version: {updateInfo.currentVersion || 'Loading...'} +

+
+ +
+
+ + + {updateInfo.isUpdateAvailable && updateStatus === 'idle' && ( + + )} +
+ + {getStatusMessage() && ( +
+ {getStatusIcon()} + {getStatusMessage()} +
+ )} + + {updateStatus === 'downloading' && ( +
+
+
+ )} +
+
+ ); +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index ec87055ada56..ac6ee576df69 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1598,6 +1598,60 @@ app.whenReady().then(async () => { console.error('Error opening URL in Chrome:', error); } }); + + // Handle update execution + ipcMain.handle('execute-update', async (_event, scriptContent) => { + try { + return new Promise((resolve) => { + const updateProcess = spawn('bash', ['-c', scriptContent], { + env: { + ...process.env, + CONFIGURE: 'false', // Skip configuration during update + }, + }); + + let _stdout = ''; + let stderr = ''; + + updateProcess.stdout.on('data', (data) => { + _stdout += data.toString(); + }); + + updateProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + updateProcess.on('close', (code) => { + if (code === 0) { + resolve({ success: true }); + } else { + resolve({ + success: false, + error: stderr || 'Update process exited with code ' + code, + }); + } + }); + + updateProcess.on('error', (error) => { + resolve({ + success: false, + error: error.message, + }); + }); + }); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + // Handle app restart + ipcMain.on('restart-app', () => { + app.relaunch(); + app.exit(0); + }); }); /** diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 13674f8827e4..19ea2d33da45 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -27,6 +27,11 @@ interface SaveDataUrlResponse { error?: string; } +interface UpdateResult { + success: boolean; + error?: string; +} + const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}'); // Define the API types in a single place @@ -76,6 +81,10 @@ type ElectronAPI = { deleteTempFile: (filePath: string) => void; // Function to serve temp images getTempImage: (filePath: string) => Promise; + // Update-related functions + getVersion: () => string; + executeUpdate: (scriptContent: string) => Promise; + restartApp: () => void; }; type AppConfigAPI = { @@ -149,6 +158,15 @@ const electronAPI: ElectronAPI = { getTempImage: (filePath: string): Promise => { return ipcRenderer.invoke('get-temp-image', filePath); }, + getVersion: (): string => { + return config.GOOSE_VERSION || ''; + }, + executeUpdate: (scriptContent: string): Promise => { + return ipcRenderer.invoke('execute-update', scriptContent); + }, + restartApp: (): void => { + ipcRenderer.send('restart-app'); + }, }; const appConfigAPI: AppConfigAPI = { From e356416ad69bacddec96e6815f9a582a7f007122 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 10 Jun 2025 13:01:20 +0200 Subject: [PATCH 02/10] docs: add implementation details for GUI update feature --- GUI_UPDATE_FEATURE.md | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 GUI_UPDATE_FEATURE.md diff --git a/GUI_UPDATE_FEATURE.md b/GUI_UPDATE_FEATURE.md new file mode 100644 index 000000000000..24fbb149e30c --- /dev/null +++ b/GUI_UPDATE_FEATURE.md @@ -0,0 +1,52 @@ +# GUI Update Feature Implementation + +This branch adds an update feature to the Goose desktop application, similar to the CLI's update command. + +## Changes Made + +### 1. New Update Section Component (`UpdateSection.tsx`) +- Check for updates by fetching the latest release from GitHub +- Compare current version with latest available version +- Download and execute the update script +- Show progress during download and installation +- Prompt user to restart the app after successful update + +### 2. Main Process Updates (`main.ts`) +- Added IPC handler for `execute-update` to run the update script +- Added IPC handler for `restart-app` to relaunch the application +- Update script runs with `CONFIGURE=false` to skip configuration during update + +### 3. Preload Script Updates (`preload.ts`) +- Added `getVersion()` method to retrieve current app version +- Added `executeUpdate()` method to execute update scripts +- Added `restartApp()` method to restart the application + +### 4. UI Integration +- Integrated UpdateSection into AppSettingsSection +- Added visual separation with a border between app settings and update section + +## How It Works + +1. User clicks "Check for Updates" button +2. App fetches latest release info from GitHub API +3. Compares versions to determine if update is available +4. If update available, shows "Download & Install" button +5. Downloads the official update script from GitHub releases +6. Executes the script through Electron IPC +7. Shows progress during download/installation +8. Prompts user to restart after successful update + +## Testing + +To test this feature: +1. Run the desktop app: `npm run start-gui` (from ui/desktop directory) +2. Navigate to Settings > App Settings +3. Scroll down to see the Updates section +4. Click "Check for Updates" + +## Future Enhancements + +- Add automatic update checks on app startup +- Support for beta/canary release channels +- Background update downloads +- Update notifications in the system tray \ No newline at end of file From 8dc92ca87f209ec290d9b5f081720b622b570ffa Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 10 Jun 2025 18:08:25 +0200 Subject: [PATCH 03/10] feat(desktop): implement auto-update functionality - Add electron-updater for handling desktop app updates - Create autoUpdater module with GitHub release integration - Update UpdateSection component with real update checking - Add IPC handlers for update operations - Configure GitHub publisher in forge config - Fix version display and error handling --- ui/desktop/forge.config.ts | 13 + ui/desktop/package-lock.json | 68 ++++- ui/desktop/package.json | 1 + .../components/settings/app/UpdateSection.tsx | 243 ++++++++++-------- ui/desktop/src/main.ts | 59 +---- ui/desktop/src/preload.ts | 30 ++- ui/desktop/src/utils/autoUpdater.ts | 136 ++++++++++ 7 files changed, 373 insertions(+), 177 deletions(-) create mode 100644 ui/desktop/src/utils/autoUpdater.ts diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index efaa79e3b051..5dc223395778 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -44,6 +44,19 @@ if (process.env['APPLE_ID'] === undefined) { module.exports = { packagerConfig: cfg, rebuildConfig: {}, + publishers: [ + { + name: '@electron-forge/publisher-github', + config: { + repository: { + owner: 'block', + name: 'goose' + }, + prerelease: false, + draft: true + } + } + ], makers: [ { name: '@electron-forge/maker-zip', diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 8114bf592a1f..a24bee717bae 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -35,6 +35,7 @@ "dotenv": "^16.4.5", "electron-log": "^5.2.2", "electron-squirrel-startup": "^1.0.1", + "electron-updater": "^6.6.2", "electron-window-state": "^5.0.3", "express": "^4.21.1", "framer-motion": "^11.11.11", @@ -5048,7 +5049,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -5563,6 +5563,19 @@ "dev": true, "license": "MIT" }, + "node_modules/builder-util-runtime": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz", + "integrity": "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -7139,6 +7152,22 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.6.2.tgz", + "integrity": "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.3.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "^7.6.3", + "tiny-typed-emitter": "^2.1.0" + } + }, "node_modules/electron-window-state": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/electron-window-state/-/electron-window-state-5.0.3.tgz", @@ -8512,7 +8541,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -8966,7 +8994,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, "license": "ISC" }, "node_modules/graphemer": { @@ -10139,7 +10166,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10254,7 +10280,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -10299,6 +10324,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10703,6 +10734,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -10711,6 +10748,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -14562,6 +14606,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -14581,7 +14631,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -15825,6 +15874,12 @@ "license": "MIT", "optional": true }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -16290,7 +16345,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/ui/desktop/package.json b/ui/desktop/package.json index f83c4f943d8a..84160e2f43c2 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -109,6 +109,7 @@ "dotenv": "^16.4.5", "electron-log": "^5.2.2", "electron-squirrel-startup": "^1.0.1", + "electron-updater": "^6.6.2", "electron-window-state": "^5.0.3", "express": "^4.21.1", "framer-motion": "^11.11.11", diff --git a/ui/desktop/src/components/settings/app/UpdateSection.tsx b/ui/desktop/src/components/settings/app/UpdateSection.tsx index 012a02cee53f..7e8357084d6a 100644 --- a/ui/desktop/src/components/settings/app/UpdateSection.tsx +++ b/ui/desktop/src/components/settings/app/UpdateSection.tsx @@ -2,7 +2,7 @@ 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'; +type UpdateStatus = 'idle' | 'checking' | 'downloading' | 'installing' | 'success' | 'error' | 'ready'; interface UpdateInfo { currentVersion: string; @@ -22,6 +22,54 @@ export default function UpdateSection() { // Get current version on mount const currentVersion = window.electron.getVersion(); setUpdateInfo((prev) => ({ ...prev, currentVersion })); + + // 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?.version, + isUpdateAvailable: true, + })); + break; + + case 'update-not-available': + setUpdateStatus('success'); + setUpdateInfo((prev) => ({ + ...prev, + isUpdateAvailable: false, + })); + setTimeout(() => setUpdateStatus('idle'), 3000); + break; + + case 'download-progress': + setUpdateStatus('downloading'); + setProgress(event.data?.percent || 0); + break; + + case 'update-downloaded': + setUpdateStatus('ready'); + setProgress(100); + break; + + case 'error': + setUpdateStatus('error'); + setUpdateInfo((prev) => ({ + ...prev, + error: event.data || 'An error occurred', + })); + setTimeout(() => setUpdateStatus('idle'), 5000); + break; + } + }); }, []); const checkForUpdates = async () => { @@ -29,31 +77,13 @@ export default function UpdateSection() { setProgress(0); try { - // Check for updates by fetching release information - const response = await fetch('https://api.github.com/repos/block/goose/releases/latest'); - - if (!response.ok) { - throw new Error('Failed to check for updates'); + const result = await window.electron.checkForUpdates(); + + if (result.error) { + throw new Error(result.error); } - const data = await response.json(); - const latestVersion = data.tag_name?.replace('v', '') || data.name; - - // Compare versions - const isUpdateAvailable = latestVersion !== updateInfo.currentVersion; - - setUpdateInfo((prev) => ({ - ...prev, - latestVersion, - isUpdateAvailable, - })); - - if (!isUpdateAvailable) { - setUpdateStatus('success'); - setTimeout(() => setUpdateStatus('idle'), 3000); - } else { - setUpdateStatus('idle'); - } + // The actual status will be handled by the updater events } catch (error) { console.error('Error checking for updates:', error); setUpdateInfo((prev) => ({ @@ -70,66 +100,30 @@ export default function UpdateSection() { setProgress(0); try { - // Simulate progress for better UX - const progressInterval = setInterval(() => { - setProgress((prev) => { - if (prev >= 90) { - clearInterval(progressInterval); - return prev; - } - return prev + Math.random() * 10; - }); - }, 300); - - // Download the update script and execute it - const scriptResponse = await fetch( - 'https://github.com/block/goose/releases/download/stable/download_cli.sh' - ); - - if (!scriptResponse.ok) { - throw new Error('Failed to download update script'); + const result = await window.electron.downloadUpdate(); + + if (!result.success) { + throw new Error(result.error || 'Failed to download update'); } - const scriptContent = await scriptResponse.text(); - - // Clear progress interval - clearInterval(progressInterval); - setProgress(100); - setUpdateStatus('installing'); - - // Execute the update through electron IPC - const result = await window.electron.executeUpdate(scriptContent); - - if (result.success) { - setUpdateStatus('success'); - setUpdateInfo((prev) => ({ - ...prev, - currentVersion: prev.latestVersion || prev.currentVersion, - isUpdateAvailable: false, - })); - - // Prompt to restart the app - setTimeout(() => { - if ( - window.confirm('Update installed successfully! Would you like to restart Goose now?') - ) { - window.electron.restartApp(); - } - }, 1000); - } else { - throw new Error(result.error || 'Failed to install update'); - } + // The download progress and completion will be handled by updater events } catch (error) { - console.error('Error downloading/installing update:', error); + console.error('Error downloading update:', error); setUpdateInfo((prev) => ({ ...prev, - error: error instanceof Error ? error.message : 'Failed to install update', + 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': @@ -138,6 +132,8 @@ export default function UpdateSection() { 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!' @@ -162,61 +158,84 @@ export default function UpdateSection() { return ; case 'error': return ; + case 'ready': + return ; default: return updateInfo.isUpdateAvailable ? : null; } }; return ( -
-
-

Updates

-

+

+
+

Updates

+
+
+

Current version: {updateInfo.currentVersion || 'Loading...'} + {updateInfo.currentVersion && updateInfo.isUpdateAvailable === false && ' (up to date)'}

-
-
-
- - - {updateInfo.isUpdateAvailable && updateStatus === 'idle' && ( +
+
- )} -
- {getStatusMessage() && ( -
- {getStatusIcon()} - {getStatusMessage()} -
- )} - - {updateStatus === 'downloading' && ( -
-
+ {updateInfo.isUpdateAvailable && updateStatus === 'idle' && ( + + )} + + {updateStatus === 'ready' && ( + + )}
- )} + + {getStatusMessage() && ( +
+ {getStatusIcon()} + {getStatusMessage()} +
+ )} + + {updateStatus === 'downloading' && ( +
+
+
+ )} + + {/* Update information */} + {updateInfo.isUpdateAvailable && ( +

+ Updates will replace the app in /Applications. Your settings and data will be preserved. +

+ )} +
); -} +} \ No newline at end of file diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index ac6ee576df69..01049327b6a5 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -35,6 +35,7 @@ import * as crypto from 'crypto'; import * as electron from 'electron'; import * as yaml from 'yaml'; import windowStateKeeper from 'electron-window-state'; +import { setupAutoUpdater } from './utils/autoUpdater'; // Define temp directory for pasted images const gooseTempDir = path.join(app.getPath('temp'), 'goose-pasted-images'); @@ -1159,6 +1160,9 @@ const registerGlobalHotkey = (accelerator: string) => { }; app.whenReady().then(async () => { + // Setup auto-updater + setupAutoUpdater(); + // Add CSP headers to all sessions session.defaultSession.webRequest.onHeadersReceived((details, callback) => { callback({ @@ -1173,7 +1177,7 @@ app.whenReady().then(async () => { // Images from our app and data: URLs (for base64 images) "img-src 'self' data: https:;" + // Connect to our local API and specific external services - "connect-src 'self' http://127.0.0.1:*" + + "connect-src 'self' http://127.0.0.1:* https://api.github.com https://github.com https://objects.githubusercontent.com" + // Don't allow any plugins "object-src 'none';" + // Don't allow any frames @@ -1599,59 +1603,16 @@ app.whenReady().then(async () => { } }); - // Handle update execution - ipcMain.handle('execute-update', async (_event, scriptContent) => { - try { - return new Promise((resolve) => { - const updateProcess = spawn('bash', ['-c', scriptContent], { - env: { - ...process.env, - CONFIGURE: 'false', // Skip configuration during update - }, - }); - - let _stdout = ''; - let stderr = ''; - - updateProcess.stdout.on('data', (data) => { - _stdout += data.toString(); - }); - - updateProcess.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - updateProcess.on('close', (code) => { - if (code === 0) { - resolve({ success: true }); - } else { - resolve({ - success: false, - error: stderr || 'Update process exited with code ' + code, - }); - } - }); - - updateProcess.on('error', (error) => { - resolve({ - success: false, - error: error.message, - }); - }); - }); - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - }); - // Handle app restart ipcMain.on('restart-app', () => { app.relaunch(); app.exit(0); }); + + // Handler for getting app version + ipcMain.on('get-app-version', (event) => { + event.returnValue = app.getVersion(); + }); }); /** diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 19ea2d33da45..5a9674135000 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -27,13 +27,13 @@ interface SaveDataUrlResponse { error?: string; } -interface UpdateResult { - success: boolean; - error?: string; -} - const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}'); +interface UpdaterEvent { + event: string; + data?: unknown; +} + // Define the API types in a single place type ElectronAPI = { platform: string; @@ -83,8 +83,11 @@ type ElectronAPI = { getTempImage: (filePath: string) => Promise; // Update-related functions getVersion: () => string; - executeUpdate: (scriptContent: string) => Promise; + checkForUpdates: () => Promise<{ updateInfo: unknown; error: string | null }>; + downloadUpdate: () => Promise<{ success: boolean; error: string | null }>; + installUpdate: () => void; restartApp: () => void; + onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => void; }; type AppConfigAPI = { @@ -159,14 +162,23 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke('get-temp-image', filePath); }, getVersion: (): string => { - return config.GOOSE_VERSION || ''; + return config.GOOSE_VERSION || ipcRenderer.sendSync('get-app-version') || ''; + }, + checkForUpdates: (): Promise<{ updateInfo: unknown; error: string | null }> => { + return ipcRenderer.invoke('check-for-updates'); }, - executeUpdate: (scriptContent: string): Promise => { - return ipcRenderer.invoke('execute-update', scriptContent); + downloadUpdate: (): Promise<{ success: boolean; error: string | null }> => { + return ipcRenderer.invoke('download-update'); + }, + installUpdate: (): void => { + ipcRenderer.invoke('install-update'); }, restartApp: (): void => { ipcRenderer.send('restart-app'); }, + onUpdaterEvent: (callback: (event: UpdaterEvent) => void): void => { + ipcRenderer.on('updater-event', (_event, data) => callback(data)); + }, }; const appConfigAPI: AppConfigAPI = { diff --git a/ui/desktop/src/utils/autoUpdater.ts b/ui/desktop/src/utils/autoUpdater.ts new file mode 100644 index 000000000000..954c863a9cfb --- /dev/null +++ b/ui/desktop/src/utils/autoUpdater.ts @@ -0,0 +1,136 @@ +import { autoUpdater, UpdateInfo } from 'electron-updater'; +import { BrowserWindow, ipcMain } from 'electron'; +import log from './logger'; + +// Configure auto-updater +export function setupAutoUpdater() { + // Set the feed URL for GitHub releases + autoUpdater.setFeedURL({ + provider: 'github', + owner: 'block', + repo: 'goose', + releaseType: 'release' + }); + + // Configure auto-updater settings + autoUpdater.autoDownload = false; // We'll trigger downloads manually + autoUpdater.autoInstallOnAppQuit = true; + + // Set logger + autoUpdater.logger = log; + + // Handle update events + autoUpdater.on('checking-for-update', () => { + log.info('Checking for update...'); + sendStatusToWindow('checking-for-update'); + }); + + autoUpdater.on('update-available', (info: UpdateInfo) => { + log.info('Update available:', info); + sendStatusToWindow('update-available', info); + }); + + autoUpdater.on('update-not-available', (info: UpdateInfo) => { + log.info('Update not available:', info); + sendStatusToWindow('update-not-available', info); + }); + + autoUpdater.on('error', (err) => { + log.error('Error in auto-updater:', err); + // Handle connection errors more gracefully + if (err.message.includes('ERR_CONNECTION_REFUSED') || err.message.includes('ENOTFOUND')) { + sendStatusToWindow('error', 'Unable to check for updates. Please check your internet connection.'); + } else if (err.message.includes('HttpError: 404')) { + // When no releases are found, assume current version is up to date + sendStatusToWindow('update-not-available', { version: autoUpdater.currentVersion.version }); + } else { + sendStatusToWindow('error', err.message); + } + }); + + 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); + }); + + autoUpdater.on('update-downloaded', (info: UpdateInfo) => { + log.info('Update downloaded:', info); + sendStatusToWindow('update-downloaded', info); + }); + + // IPC handlers for renderer process + ipcMain.handle('check-for-updates', async () => { + try { + // Ensure auto-updater is properly initialized + if (!autoUpdater.currentVersion) { + throw new Error('Auto-updater not initialized. Please restart the application.'); + } + + const result = await autoUpdater.checkForUpdates(); + return { + updateInfo: result?.updateInfo, + error: null + }; + } catch (error) { + log.error('Error checking for updates:', error); + let errorMessage = 'Unknown error'; + + if (error instanceof Error) { + if (error.message.includes('ERR_CONNECTION_REFUSED') || error.message.includes('ENOTFOUND')) { + errorMessage = 'Unable to check for updates. Please check your internet connection.'; + } else if (error.message.includes('HttpError: 404')) { + // When no releases are found, treat as up to date + // This will trigger the update-not-available event + sendStatusToWindow('update-not-available', { version: autoUpdater.currentVersion.version }); + return { + updateInfo: null, + error: null + }; + } else { + errorMessage = error.message; + } + } + + return { + updateInfo: null, + error: errorMessage + }; + } + }); + + ipcMain.handle('download-update', async () => { + try { + await autoUpdater.downloadUpdate(); + return { success: true, error: null }; + } catch (error) { + log.error('Error downloading update:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + }); + + ipcMain.handle('install-update', () => { + autoUpdater.quitAndInstall(false, true); + }); + + ipcMain.handle('get-current-version', () => { + return autoUpdater.currentVersion.version; + }); +} + +interface UpdaterEvent { + event: string; + data?: unknown; +} + +function sendStatusToWindow(event: string, data?: unknown) { + const windows = BrowserWindow.getAllWindows(); + windows.forEach((win) => { + win.webContents.send('updater-event', { event, data } as UpdaterEvent); + }); +} \ No newline at end of file From d4e10917bba369ff4a11c87ba2bc72f102ef3fe4 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 10 Jun 2025 18:16:29 +0200 Subject: [PATCH 04/10] feat(desktop): add auto-update check on launch and menubar indicator - Check for updates automatically 5 seconds after app launch - Add red dot indicator to menubar icon when updates are available - Create iconTemplateUpdate.png with red dot overlay - Update tray icon dynamically based on update availability - Pass tray reference to auto-updater for icon management --- ui/desktop/scripts/generate-update-icon.js | 53 ++++++++++++++ ui/desktop/src/images/iconTemplateUpdate.png | Bin 0 -> 630 bytes .../src/images/iconTemplateUpdate@2x.png | Bin 0 -> 1403 bytes ui/desktop/src/main.ts | 5 +- ui/desktop/src/utils/autoUpdater.ts | 67 +++++++++++++++++- 5 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 ui/desktop/scripts/generate-update-icon.js create mode 100644 ui/desktop/src/images/iconTemplateUpdate.png create mode 100644 ui/desktop/src/images/iconTemplateUpdate@2x.png diff --git a/ui/desktop/scripts/generate-update-icon.js b/ui/desktop/scripts/generate-update-icon.js new file mode 100644 index 000000000000..edf533ce6d18 --- /dev/null +++ b/ui/desktop/scripts/generate-update-icon.js @@ -0,0 +1,53 @@ +const { createCanvas, loadImage } = require('canvas'); +const fs = require('fs'); +const path = require('path'); + +async function generateUpdateIcon() { + // Load the original icon + const iconPath = path.join(__dirname, '../src/images/iconTemplate.png'); + const icon = await loadImage(iconPath); + + // Create canvas + const canvas = createCanvas(22, 22); + const ctx = canvas.getContext('2d'); + + // Draw the original icon + ctx.drawImage(icon, 0, 0); + + // Add red dot in top-right corner + ctx.fillStyle = '#FF0000'; + ctx.beginPath(); + ctx.arc(18, 4, 3, 0, 2 * Math.PI); + ctx.fill(); + + // Save the new icon + const outputPath = path.join(__dirname, '../src/images/iconTemplateUpdate.png'); + const buffer = canvas.toBuffer('image/png'); + fs.writeFileSync(outputPath, buffer); + + console.log('Generated update icon at:', outputPath); + + // Also generate @2x version + const canvas2x = createCanvas(44, 44); + const ctx2x = canvas2x.getContext('2d'); + + // Load and draw @2x version + const icon2xPath = path.join(__dirname, '../src/images/iconTemplate@2x.png'); + const icon2x = await loadImage(icon2xPath); + ctx2x.drawImage(icon2x, 0, 0); + + // Add red dot in top-right corner (scaled) + ctx2x.fillStyle = '#FF0000'; + ctx2x.beginPath(); + ctx2x.arc(36, 8, 6, 0, 2 * Math.PI); + ctx2x.fill(); + + // Save the @2x version + const output2xPath = path.join(__dirname, '../src/images/iconTemplateUpdate@2x.png'); + const buffer2x = canvas2x.toBuffer('image/png'); + fs.writeFileSync(output2xPath, buffer2x); + + console.log('Generated @2x update icon at:', output2xPath); +} + +generateUpdateIcon().catch(console.error); \ No newline at end of file diff --git a/ui/desktop/src/images/iconTemplateUpdate.png b/ui/desktop/src/images/iconTemplateUpdate.png new file mode 100644 index 0000000000000000000000000000000000000000..a3f8b6fb2693380108afbd0fafca55dd2dc278ea GIT binary patch literal 630 zcmV-+0*U>JP)kn&FDqlrjT3RauO0+XfM($}V4*L2 ze0)51&aGN&Th>}{jA>hIYsMJYo#&jpX|4TXjJY?cno9RWyWP&BD4GCH0Zv3lfYTzP z(==TJa_8JN@BJO%ah~T(olfU_8F-{Okmvbl5fLB*R1uzc&b`a?yz0GQ09KMDxeII$ zr5GT~vW+kdE8hEyh2rhv1G6G>8Cca?-)^;9ok7r2FyO;5tc%D|;0rs=+h0`Yfv1f| zW1aF*+Y?NhrioJObr1xXfRkNE0-OuO@SsxaRhDJxAAtd)D7ppAmmG}(X8=>G5S^-pi-&K0*8TbBJ#|8e+5`$XI*!j zHVP~b+R#$PVHlnjk+&kUSglqcHk-}&N~v`b*#Zs$IXlzhfD1~gdX{AybTxF)Ew(#m zeHA+Au6yr~iO6y9{SjaT_@K4Ezqz^D9>5#gDq8D_IF6^Mrlz7j1pYVp1v|5~v6C-L QRsaA107*qoM6N<$f*{r#YXATM literal 0 HcmV?d00001 diff --git a/ui/desktop/src/images/iconTemplateUpdate@2x.png b/ui/desktop/src/images/iconTemplateUpdate@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..deb016802f023e181043c33cdd08319610da3ceb GIT binary patch literal 1403 zcmV->1%&#EP)fP z=x*IM?e5H4CDA^~Bs*um^PT6Me0$CeFvWiy0UQAy7vQh}`Ra98fOP^m*V=Cs0?ZcR zxBxQ2bxeTS*V+@n$jUa7t(U&`D_t#vX0o;Gv1{!M0gl8FJ`^{9;vW#s#SqTb`l$%H zmDYNL@B1qzTRH||5XKl6QGs@Gdu>(2OoqY=Q#lix+$){?b;%~n_|rJf*p0>H^?+iC!> zYpuImbfxEc3w*7g?DM?zC32ua>~R1q2?VtUt5N23I^Cg^+9V>Y01!YMhv^}CkK;Ih z7-N0_P>Cp;)LK8SlzNrq9a-R=gXANt%jGiwrUCFVEO!*Z3jo#<$c&n8lL0)f^#PL0 z#(O9tMF4M?%jM^WhK4RQv(;L!1+blDLPW}87(S58<#tDy=m-s07(m%^oFNR)JPp7l zIWkF-Hpyj5sassvJ!p)%GEt&*I^Cy~dYPnO?R%(PE^jZDO8LnY7R$0OlRQWA0)Uw$ zuNxagvKv5;Wm)^j6G){}y-KN_ByR=)5!qX*RMrg*4V{T28$}pxZEedf%i0BCIe-}? z$Ni&{>{d#7#+ZGmt7*^k?zV0FO_H|*7y_^rz{XrI_j6p?NW#fv@(RiONY0r^wvJxc zb^DDmCjmff{h(56CxC?_Vn{xl$z(PgV+yUvMiLH!;A*vw=K{D|Z5~z2HL5ke$aUTG zuIoNT@_7JVA|e2`WwY7Mh;z)2L$h6Cgj6ckV_DY20PY7cZ#>}|B2prmsJnkJo6SCo zx|f(%iSqJOAG?a={IRkkQmVfnBYP+af@d%ZvNefFJo%F{fPD{ZT}21+W{y9sqZdobbDg2L- zY(^`>UVD4{4h$b8u^>4gz+$D;fpWP#*uvQHg|*hJ0X)@cTWbW#n{3Q;z>6erh&xa{BGTi!?tn4oXxzSW_F+^il|BGVCy)pLWTWIEX#?1pN~L<^ zj1R;VR!Xe|&^ESRL@tWR1^{aSoR5-=WVdbGTjG8rMlOktj*gj@Wo-h`Ro6TyB74Fx zT%XJ3-Y*u5{f^_D1+ao-NA>w&_4jxzP)c<08!2*8l+7m#{qB~$xad3B_aiqO8_K9 zWG{frByS{ny;5pMB9YkND1S?PX@A21%!4y+WF~$FlKLL+WVY>hRoI3yj002ov JPDHLkV1hPQg0cVr literal 0 HcmV?d00001 diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 01049327b6a5..fec0a9a8a8d7 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -35,7 +35,7 @@ import * as crypto from 'crypto'; import * as electron from 'electron'; import * as yaml from 'yaml'; import windowStateKeeper from 'electron-window-state'; -import { setupAutoUpdater } from './utils/autoUpdater'; +import { setupAutoUpdater, setTrayRef } from './utils/autoUpdater'; // Define temp directory for pasted images const gooseTempDir = path.join(app.getPath('temp'), 'goose-pasted-images'); @@ -607,6 +607,9 @@ const createTray = () => { } tray = new Tray(iconPath); + + // Set tray reference for auto-updater + setTrayRef(tray); const contextMenu = Menu.buildFromTemplate([ { label: 'Show Window', click: showWindow }, diff --git a/ui/desktop/src/utils/autoUpdater.ts b/ui/desktop/src/utils/autoUpdater.ts index 954c863a9cfb..a1cba11f3f49 100644 --- a/ui/desktop/src/utils/autoUpdater.ts +++ b/ui/desktop/src/utils/autoUpdater.ts @@ -1,9 +1,17 @@ import { autoUpdater, UpdateInfo } from 'electron-updater'; -import { BrowserWindow, ipcMain } from 'electron'; +import { BrowserWindow, ipcMain, nativeImage, Tray } from 'electron'; +import * as path from 'path'; import log from './logger'; +let updateAvailable = false; +let trayRef: Tray | null = null; + // Configure auto-updater -export function setupAutoUpdater() { +export function setupAutoUpdater(tray?: Tray) { + if (tray) { + trayRef = tray; + } + // Set the feed URL for GitHub releases autoUpdater.setFeedURL({ provider: 'github', @@ -18,6 +26,14 @@ export function setupAutoUpdater() { // Set logger autoUpdater.logger = log; + + // Check for updates on startup + setTimeout(() => { + log.info('Checking for updates on startup...'); + autoUpdater.checkForUpdates().catch(err => { + log.error('Error checking for updates on startup:', err); + }); + }, 5000); // Wait 5 seconds after app starts // Handle update events autoUpdater.on('checking-for-update', () => { @@ -27,11 +43,15 @@ export function setupAutoUpdater() { autoUpdater.on('update-available', (info: UpdateInfo) => { log.info('Update available:', info); + updateAvailable = true; + updateTrayIcon(true); sendStatusToWindow('update-available', info); }); autoUpdater.on('update-not-available', (info: UpdateInfo) => { log.info('Update not available:', info); + updateAvailable = false; + updateTrayIcon(false); sendStatusToWindow('update-not-available', info); }); @@ -133,4 +153,47 @@ function sendStatusToWindow(event: string, data?: unknown) { windows.forEach((win) => { win.webContents.send('updater-event', { event, data } as UpdaterEvent); }); +} + +function updateTrayIcon(hasUpdate: boolean) { + if (!trayRef) return; + + const isDev = process.env.NODE_ENV === 'development'; + let iconPath: string; + + if (hasUpdate) { + // Use icon with update indicator + if (isDev) { + iconPath = path.join(process.cwd(), 'src', 'images', 'iconTemplateUpdate.png'); + } else { + iconPath = path.join(process.resourcesPath, 'images', 'iconTemplateUpdate.png'); + } + trayRef.setToolTip('Goose - Update Available'); + } else { + // Use normal icon + if (isDev) { + iconPath = path.join(process.cwd(), 'src', 'images', 'iconTemplate.png'); + } else { + iconPath = path.join(process.resourcesPath, 'images', 'iconTemplate.png'); + } + trayRef.setToolTip('Goose'); + } + + const icon = nativeImage.createFromPath(iconPath); + if (process.platform === 'darwin') { + // Mark as template for macOS to handle dark/light mode + icon.setTemplateImage(true); + } + trayRef.setImage(icon); +} + +// Export functions to manage tray reference +export function setTrayRef(tray: Tray) { + trayRef = tray; + // Update icon based on current update status + updateTrayIcon(updateAvailable); +} + +export function getUpdateAvailable(): boolean { + return updateAvailable; } \ No newline at end of file From 439c0fd861ace84e989159be582ae643209f9796 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 10 Jun 2025 23:07:11 +0200 Subject: [PATCH 05/10] feat: enhance auto-updater with auto-extraction and startup checks - Add automatic update checking on app startup (5s delay) - Implement auto-extraction of downloaded ZIP files - Add GitHub API fallback for update checking - Store and display update state in settings - Show visual indicator in menu bar when updates available - Bump version to 1.0.27 - Remove temporary documentation files --- GUI_UPDATE_FEATURE.md | 52 ---- ui/desktop/package-lock.json | 7 + ui/desktop/package.json | 1 + .../components/settings/app/UpdateSection.tsx | 81 ++++-- ui/desktop/src/main.ts | 4 +- ui/desktop/src/preload.ts | 4 + ui/desktop/src/utils/autoUpdater.ts | 273 +++++++++++++++--- ui/desktop/src/utils/githubUpdater.ts | 247 ++++++++++++++++ 8 files changed, 551 insertions(+), 118 deletions(-) delete mode 100644 GUI_UPDATE_FEATURE.md create mode 100644 ui/desktop/src/utils/githubUpdater.ts diff --git a/GUI_UPDATE_FEATURE.md b/GUI_UPDATE_FEATURE.md deleted file mode 100644 index 24fbb149e30c..000000000000 --- a/GUI_UPDATE_FEATURE.md +++ /dev/null @@ -1,52 +0,0 @@ -# GUI Update Feature Implementation - -This branch adds an update feature to the Goose desktop application, similar to the CLI's update command. - -## Changes Made - -### 1. New Update Section Component (`UpdateSection.tsx`) -- Check for updates by fetching the latest release from GitHub -- Compare current version with latest available version -- Download and execute the update script -- Show progress during download and installation -- Prompt user to restart the app after successful update - -### 2. Main Process Updates (`main.ts`) -- Added IPC handler for `execute-update` to run the update script -- Added IPC handler for `restart-app` to relaunch the application -- Update script runs with `CONFIGURE=false` to skip configuration during update - -### 3. Preload Script Updates (`preload.ts`) -- Added `getVersion()` method to retrieve current app version -- Added `executeUpdate()` method to execute update scripts -- Added `restartApp()` method to restart the application - -### 4. UI Integration -- Integrated UpdateSection into AppSettingsSection -- Added visual separation with a border between app settings and update section - -## How It Works - -1. User clicks "Check for Updates" button -2. App fetches latest release info from GitHub API -3. Compares versions to determine if update is available -4. If update available, shows "Download & Install" button -5. Downloads the official update script from GitHub releases -6. Executes the script through Electron IPC -7. Shows progress during download/installation -8. Prompts user to restart after successful update - -## Testing - -To test this feature: -1. Run the desktop app: `npm run start-gui` (from ui/desktop directory) -2. Navigate to Settings > App Settings -3. Scroll down to see the Updates section -4. Click "Check for Updates" - -## Future Enhancements - -- Add automatic update checks on app startup -- Support for beta/canary release channels -- Background update downloads -- Update notifications in the system tray \ No newline at end of file diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index a24bee717bae..27ccf095403a 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -30,6 +30,7 @@ "ai": "^3.4.33", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "compare-versions": "^6.1.1", "cors": "^2.8.5", "cronstrue": "^2.48.0", "dotenv": "^16.4.5", @@ -6153,6 +6154,12 @@ "node": ">=0.10.0" } }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 84160e2f43c2..2646f2b32ab1 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -104,6 +104,7 @@ "ai": "^3.4.33", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "compare-versions": "^6.1.1", "cors": "^2.8.5", "cronstrue": "^2.48.0", "dotenv": "^16.4.5", diff --git a/ui/desktop/src/components/settings/app/UpdateSection.tsx b/ui/desktop/src/components/settings/app/UpdateSection.tsx index 7e8357084d6a..3d0639a334d7 100644 --- a/ui/desktop/src/components/settings/app/UpdateSection.tsx +++ b/ui/desktop/src/components/settings/app/UpdateSection.tsx @@ -2,7 +2,14 @@ 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'; +type UpdateStatus = + | 'idle' + | 'checking' + | 'downloading' + | 'installing' + | 'success' + | 'error' + | 'ready'; interface UpdateInfo { currentVersion: string; @@ -11,6 +18,11 @@ interface UpdateInfo { error?: string; } +interface UpdateEventData { + version?: string; + percent?: number; +} + export default function UpdateSection() { const [updateStatus, setUpdateStatus] = useState('idle'); const [updateInfo, setUpdateInfo] = useState({ @@ -23,48 +35,59 @@ export default function UpdateSection() { 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?.version, + latestVersion: (event.data as UpdateEventData)?.version, isUpdateAvailable: true, })); break; - + case 'update-not-available': - setUpdateStatus('success'); + setUpdateStatus('idle'); setUpdateInfo((prev) => ({ ...prev, isUpdateAvailable: false, })); - setTimeout(() => setUpdateStatus('idle'), 3000); break; - + case 'download-progress': setUpdateStatus('downloading'); - setProgress(event.data?.percent || 0); + setProgress((event.data as UpdateEventData)?.percent || 0); break; - + case 'update-downloaded': setUpdateStatus('ready'); setProgress(100); break; - + case 'error': setUpdateStatus('error'); setUpdateInfo((prev) => ({ ...prev, - error: event.data || 'An error occurred', + error: String(event.data || 'An error occurred'), })); setTimeout(() => setUpdateStatus('idle'), 5000); break; @@ -78,11 +101,16 @@ export default function UpdateSection() { 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); @@ -101,7 +129,7 @@ export default function UpdateSection() { try { const result = await window.electron.downloadUpdate(); - + if (!result.success) { throw new Error(result.error || 'Failed to download update'); } @@ -137,7 +165,7 @@ export default function UpdateSection() { case 'success': return updateInfo.isUpdateAvailable === false ? 'You are running the latest version!' - : 'Update installed successfully!'; + : 'Update available!'; case 'error': return updateInfo.error || 'An error occurred'; default: @@ -173,6 +201,9 @@ export default function UpdateSection() {

Current version: {updateInfo.currentVersion || 'Loading...'} + {updateInfo.latestVersion && updateInfo.isUpdateAvailable && ( + → {updateInfo.latestVersion} available + )} {updateInfo.currentVersion && updateInfo.isUpdateAvailable === false && ' (up to date)'}

@@ -201,12 +232,7 @@ export default function UpdateSection() { )} {updateStatus === 'ready' && ( - )} @@ -227,15 +253,18 @@ export default function UpdateSection() { />
)} - + {/* Update information */} {updateInfo.isUpdateAvailable && ( -

- Updates will replace the app in /Applications. Your settings and data will be preserved. -

+
+

Update will be downloaded and automatically extracted to your Downloads folder.

+

+ After download, move the Goose app to /Applications to complete the update. +

+
)}
); -} \ No newline at end of file +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index fec0a9a8a8d7..49219cefb1d9 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -607,7 +607,7 @@ const createTray = () => { } tray = new Tray(iconPath); - + // Set tray reference for auto-updater setTrayRef(tray); @@ -1165,7 +1165,7 @@ const registerGlobalHotkey = (accelerator: string) => { app.whenReady().then(async () => { // Setup auto-updater setupAutoUpdater(); - + // Add CSP headers to all sessions session.defaultSession.webRequest.onHeadersReceived((details, callback) => { callback({ diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 5a9674135000..d79f68a5d6fc 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -88,6 +88,7 @@ type ElectronAPI = { installUpdate: () => void; restartApp: () => void; onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => void; + getUpdateState: () => Promise<{ updateAvailable: boolean; latestVersion?: string } | null>; }; type AppConfigAPI = { @@ -179,6 +180,9 @@ const electronAPI: ElectronAPI = { onUpdaterEvent: (callback: (event: UpdaterEvent) => void): void => { ipcRenderer.on('updater-event', (_event, data) => callback(data)); }, + getUpdateState: (): Promise<{ updateAvailable: boolean; latestVersion?: string } | null> => { + return ipcRenderer.invoke('get-update-state'); + }, }; const appConfigAPI: AppConfigAPI = { diff --git a/ui/desktop/src/utils/autoUpdater.ts b/ui/desktop/src/utils/autoUpdater.ts index a1cba11f3f49..58dd5743085f 100644 --- a/ui/desktop/src/utils/autoUpdater.ts +++ b/ui/desktop/src/utils/autoUpdater.ts @@ -1,37 +1,80 @@ import { autoUpdater, UpdateInfo } from 'electron-updater'; -import { BrowserWindow, ipcMain, nativeImage, Tray } from 'electron'; +import { BrowserWindow, ipcMain, nativeImage, Tray, shell, app, dialog } from 'electron'; import * as path from 'path'; +import * as fs from 'fs/promises'; import log from './logger'; +import { githubUpdater } from './githubUpdater'; let updateAvailable = false; let trayRef: Tray | null = null; +let isUsingGitHubFallback = false; +let githubUpdateInfo: { latestVersion?: string; downloadUrl?: string; releaseUrl?: string; downloadPath?: string; extractedPath?: string } = {}; + +// Store update state +let lastUpdateState: { updateAvailable: boolean; latestVersion?: string } | null = null; // Configure auto-updater export function setupAutoUpdater(tray?: Tray) { if (tray) { trayRef = tray; } - + // Set the feed URL for GitHub releases autoUpdater.setFeedURL({ provider: 'github', owner: 'block', repo: 'goose', - releaseType: 'release' + releaseType: 'release', }); // Configure auto-updater settings autoUpdater.autoDownload = false; // We'll trigger downloads manually autoUpdater.autoInstallOnAppQuit = true; - + // Set logger autoUpdater.logger = log; - + // Check for updates on startup setTimeout(() => { log.info('Checking for updates on startup...'); - autoUpdater.checkForUpdates().catch(err => { + autoUpdater.checkForUpdates().catch((err) => { log.error('Error checking for updates on startup:', err); + // If electron-updater fails, try GitHub API as fallback + if ( + err.message.includes('HttpError: 404') || + err.message.includes('ERR_CONNECTION_REFUSED') || + err.message.includes('ENOTFOUND') + ) { + log.info('Using GitHub API fallback for startup update check...'); + isUsingGitHubFallback = true; + + githubUpdater.checkForUpdates().then((result) => { + if (result.error) { + sendStatusToWindow('error', result.error); + } else if (result.updateAvailable) { + // Store GitHub update info + githubUpdateInfo = { + latestVersion: result.latestVersion, + downloadUrl: result.downloadUrl, + releaseUrl: result.releaseUrl, + }; + + updateAvailable = true; + lastUpdateState = { updateAvailable: true, latestVersion: result.latestVersion }; + updateTrayIcon(true); + sendStatusToWindow('update-available', { version: result.latestVersion }); + } else { + updateAvailable = false; + lastUpdateState = { updateAvailable: false }; + updateTrayIcon(false); + sendStatusToWindow('update-not-available', { + version: autoUpdater.currentVersion.version, + }); + } + }).catch((fallbackError) => { + log.error('GitHub fallback also failed on startup:', fallbackError); + }); + } }); }, 5000); // Wait 5 seconds after app starts @@ -44,6 +87,7 @@ export function setupAutoUpdater(tray?: Tray) { autoUpdater.on('update-available', (info: UpdateInfo) => { log.info('Update available:', info); updateAvailable = true; + lastUpdateState = { updateAvailable: true, latestVersion: info.version }; updateTrayIcon(true); sendStatusToWindow('update-available', info); }); @@ -51,18 +95,53 @@ export function setupAutoUpdater(tray?: Tray) { autoUpdater.on('update-not-available', (info: UpdateInfo) => { log.info('Update not available:', info); updateAvailable = false; + lastUpdateState = { updateAvailable: false }; updateTrayIcon(false); sendStatusToWindow('update-not-available', info); }); - autoUpdater.on('error', (err) => { + autoUpdater.on('error', async (err) => { log.error('Error in auto-updater:', err); - // Handle connection errors more gracefully - if (err.message.includes('ERR_CONNECTION_REFUSED') || err.message.includes('ENOTFOUND')) { - sendStatusToWindow('error', 'Unable to check for updates. Please check your internet connection.'); - } else if (err.message.includes('HttpError: 404')) { - // When no releases are found, assume current version is up to date - sendStatusToWindow('update-not-available', { version: autoUpdater.currentVersion.version }); + + // Check if this is a 404 error (missing update files) or connection error + if ( + err.message.includes('HttpError: 404') || + err.message.includes('ERR_CONNECTION_REFUSED') || + err.message.includes('ENOTFOUND') + ) { + log.info('Falling back to GitHub API for update check...'); + isUsingGitHubFallback = true; + + try { + const result = await githubUpdater.checkForUpdates(); + + if (result.error) { + sendStatusToWindow('error', result.error); + } else if (result.updateAvailable) { + // Store GitHub update info + githubUpdateInfo = { + latestVersion: result.latestVersion, + downloadUrl: result.downloadUrl, + releaseUrl: result.releaseUrl, + }; + + updateAvailable = true; + updateTrayIcon(true); + sendStatusToWindow('update-available', { version: result.latestVersion }); + } else { + updateAvailable = false; + updateTrayIcon(false); + sendStatusToWindow('update-not-available', { + version: autoUpdater.currentVersion.version, + }); + } + } catch (fallbackError) { + log.error('GitHub fallback also failed:', fallbackError); + sendStatusToWindow( + 'error', + 'Unable to check for updates. Please check your internet connection.' + ); + } } else { sendStatusToWindow('error', err.message); } @@ -84,63 +163,181 @@ export function setupAutoUpdater(tray?: Tray) { // IPC handlers for renderer process ipcMain.handle('check-for-updates', async () => { try { + // Reset fallback flag + isUsingGitHubFallback = false; + githubUpdateInfo = {}; + // Ensure auto-updater is properly initialized if (!autoUpdater.currentVersion) { throw new Error('Auto-updater not initialized. Please restart the application.'); } - + const result = await autoUpdater.checkForUpdates(); return { updateInfo: result?.updateInfo, - error: null + error: null, }; } catch (error) { log.error('Error checking for updates:', error); - let errorMessage = 'Unknown error'; - - if (error instanceof Error) { - if (error.message.includes('ERR_CONNECTION_REFUSED') || error.message.includes('ENOTFOUND')) { - errorMessage = 'Unable to check for updates. Please check your internet connection.'; - } else if (error.message.includes('HttpError: 404')) { - // When no releases are found, treat as up to date - // This will trigger the update-not-available event - sendStatusToWindow('update-not-available', { version: autoUpdater.currentVersion.version }); + + // If electron-updater fails, try GitHub API fallback + if ( + error instanceof Error && + (error.message.includes('HttpError: 404') || + error.message.includes('ERR_CONNECTION_REFUSED') || + error.message.includes('ENOTFOUND')) + ) { + log.info('Using GitHub API fallback in check-for-updates...'); + isUsingGitHubFallback = true; + + try { + const result = await githubUpdater.checkForUpdates(); + + if (result.error) { + return { + updateInfo: null, + error: result.error, + }; + } + + // Store GitHub update info + if (result.updateAvailable) { + githubUpdateInfo = { + latestVersion: result.latestVersion, + downloadUrl: result.downloadUrl, + releaseUrl: result.releaseUrl, + }; + + updateAvailable = true; + lastUpdateState = { updateAvailable: true, latestVersion: result.latestVersion }; + updateTrayIcon(true); + sendStatusToWindow('update-available', { version: result.latestVersion }); + } else { + updateAvailable = false; + lastUpdateState = { updateAvailable: false }; + updateTrayIcon(false); + sendStatusToWindow('update-not-available', { + version: autoUpdater.currentVersion.version, + }); + } + return { updateInfo: null, - error: null + error: null, + }; + } catch (fallbackError) { + log.error('GitHub fallback also failed:', fallbackError); + return { + updateInfo: null, + error: 'Unable to check for updates. Please check your internet connection.', }; - } else { - errorMessage = error.message; } } - + return { updateInfo: null, - error: errorMessage + error: error instanceof Error ? error.message : 'Unknown error', }; } }); ipcMain.handle('download-update', async () => { try { - await autoUpdater.downloadUpdate(); - return { success: true, error: null }; + if (isUsingGitHubFallback && githubUpdateInfo.downloadUrl && githubUpdateInfo.latestVersion) { + log.info('Using GitHub fallback for download...'); + + const result = await githubUpdater.downloadUpdate( + githubUpdateInfo.downloadUrl, + githubUpdateInfo.latestVersion, + (percent) => { + sendStatusToWindow('download-progress', { percent }); + } + ); + + if (result.success && result.downloadPath) { + githubUpdateInfo.downloadPath = result.downloadPath; + githubUpdateInfo.extractedPath = result.extractedPath; + sendStatusToWindow('update-downloaded', { version: githubUpdateInfo.latestVersion }); + return { success: true, error: null }; + } else { + throw new Error(result.error || 'Download failed'); + } + } else { + // Use electron-updater + await autoUpdater.downloadUpdate(); + return { success: true, error: null }; + } } catch (error) { log.error('Error downloading update:', error); return { success: false, - error: error instanceof Error ? error.message : 'Unknown error' + error: error instanceof Error ? error.message : 'Unknown error', }; } }); - ipcMain.handle('install-update', () => { - autoUpdater.quitAndInstall(false, true); + ipcMain.handle('install-update', async () => { + if (isUsingGitHubFallback) { + // For GitHub fallback, we need to handle the installation differently + log.info('Installing update from GitHub fallback...'); + + try { + // Use the stored extracted path if available, otherwise download path + const updatePath = githubUpdateInfo.extractedPath || githubUpdateInfo.downloadPath; + + if (!updatePath) { + throw new Error('Update file path not found. Please download the update first.'); + } + + // Check if the update path exists + try { + await fs.access(updatePath); + } catch { + throw new Error('Update file not found. Please download the update first.'); + } + + // Show dialog to inform user about manual installation + const isExtracted = !!githubUpdateInfo.extractedPath; + const dialogResult = (await dialog.showMessageBox({ + type: 'info', + title: 'Update Ready', + message: isExtracted + ? 'The update has been downloaded and extracted to your Downloads folder.' + : 'The update has been downloaded to your Downloads folder.', + detail: isExtracted + ? `Please move the Goose app from ${path.basename(updatePath)} to your Applications folder to complete the update.` + : `Please extract ${path.basename(updatePath)} and move the Goose app to your Applications folder to complete the update.`, + buttons: ['Open Downloads', 'Cancel'], + defaultId: 0, + cancelId: 1, + })) as unknown as { response: number }; + + if (dialogResult.response === 0) { + // Open the extracted folder or show the zip file + shell.showItemInFolder(updatePath); + + // Optionally quit the app so user can replace it + setTimeout(() => { + app.quit(); + }, 1000); + } + } catch (error) { + log.error('Error installing GitHub update:', error); + throw error; + } + } else { + // Use electron-updater's built-in install + autoUpdater.quitAndInstall(false, true); + } }); ipcMain.handle('get-current-version', () => { return autoUpdater.currentVersion.version; }); + + ipcMain.handle('get-update-state', () => { + return lastUpdateState; + }); } interface UpdaterEvent { @@ -157,10 +354,10 @@ function sendStatusToWindow(event: string, data?: unknown) { function updateTrayIcon(hasUpdate: boolean) { if (!trayRef) return; - + const isDev = process.env.NODE_ENV === 'development'; let iconPath: string; - + if (hasUpdate) { // Use icon with update indicator if (isDev) { @@ -178,7 +375,7 @@ function updateTrayIcon(hasUpdate: boolean) { } trayRef.setToolTip('Goose'); } - + const icon = nativeImage.createFromPath(iconPath); if (process.platform === 'darwin') { // Mark as template for macOS to handle dark/light mode @@ -196,4 +393,4 @@ export function setTrayRef(tray: Tray) { export function getUpdateAvailable(): boolean { return updateAvailable; -} \ No newline at end of file +} diff --git a/ui/desktop/src/utils/githubUpdater.ts b/ui/desktop/src/utils/githubUpdater.ts new file mode 100644 index 000000000000..6f0f99cefcea --- /dev/null +++ b/ui/desktop/src/utils/githubUpdater.ts @@ -0,0 +1,247 @@ +import { app } from 'electron'; +import { compareVersions } from 'compare-versions'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import log from './logger'; + +const execAsync = promisify(exec); + +interface GitHubRelease { + tag_name: string; + name: string; + published_at: string; + html_url: string; + assets: Array<{ + name: string; + browser_download_url: string; + size: number; + }>; +} + +interface UpdateCheckResult { + updateAvailable: boolean; + latestVersion?: string; + downloadUrl?: string; + releaseUrl?: string; + error?: string; +} + +export class GitHubUpdater { + private readonly owner = 'block'; + private readonly repo = 'goose'; + private readonly apiUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/latest`; + + async checkForUpdates(): Promise { + try { + log.info('GitHubUpdater: Checking for updates via GitHub API...'); + + const response = await fetch(this.apiUrl, { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': `Goose-Desktop/${app.getVersion()}`, + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`); + } + + const release: GitHubRelease = await response.json(); + const latestVersion = release.tag_name.replace(/^v/, ''); // Remove 'v' prefix if present + const currentVersion = app.getVersion(); + + log.info( + `GitHubUpdater: Current version: ${currentVersion}, Latest version: ${latestVersion}` + ); + + // Compare versions + const updateAvailable = compareVersions(latestVersion, currentVersion) > 0; + + if (!updateAvailable) { + return { + updateAvailable: false, + latestVersion, + }; + } + + // Find the appropriate download URL based on platform + const platform = process.platform; + const arch = process.arch; + let downloadUrl: string | undefined; + let assetName: string; + + if (platform === 'darwin') { + // macOS + if (arch === 'arm64') { + assetName = 'Goose.zip'; + } else { + assetName = 'Goose_intel_mac.zip'; + } + } else if (platform === 'win32') { + // Windows - for future support + assetName = 'Goose-win32-x64.zip'; + } else { + // Linux - for future support + assetName = `Goose-linux-${arch}.zip`; + } + + const asset = release.assets.find((a) => a.name === assetName); + if (asset) { + downloadUrl = asset.browser_download_url; + } + + return { + updateAvailable: true, + latestVersion, + downloadUrl, + releaseUrl: release.html_url, + }; + } catch (error) { + log.error('GitHubUpdater: Error checking for updates:', error); + return { + updateAvailable: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + async downloadUpdate( + downloadUrl: string, + latestVersion: string, + onProgress?: (percent: number) => void + ): Promise<{ success: boolean; downloadPath?: string; extractedPath?: string; error?: string }> { + try { + log.info(`GitHubUpdater: Downloading update from ${downloadUrl}`); + + const response = await fetch(downloadUrl); + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } + + // Get total size from headers + const contentLength = response.headers.get('content-length'); + const totalSize = contentLength ? parseInt(contentLength, 10) : 0; + + if (!response.body) { + throw new Error('Response body is null'); + } + + // Read the response stream + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let downloadedSize = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + downloadedSize += value.length; + + // Report progress + if (totalSize > 0 && onProgress) { + const percent = Math.round((downloadedSize / totalSize) * 100); + onProgress(percent); + } + } + + // Combine chunks into a single buffer + // eslint-disable-next-line no-undef + const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); + + // Save to Downloads directory + const downloadsDir = path.join(os.homedir(), 'Downloads'); + const fileName = `Goose-${latestVersion}.zip`; + const downloadPath = path.join(downloadsDir, fileName); + + await fs.writeFile(downloadPath, buffer); + + log.info(`GitHubUpdater: Update downloaded to ${downloadPath}`); + + // Auto-unzip the downloaded file + try { + const tempExtractDir = path.join(downloadsDir, `temp-extract-${Date.now()}`); + + // Create temp extraction directory + await fs.mkdir(tempExtractDir, { recursive: true }); + + // Use unzip command to extract + log.info(`GitHubUpdater: Extracting ${fileName} to temp directory`); + + const { stderr } = await execAsync( + `unzip -o "${downloadPath}" -d "${tempExtractDir}"`, + { maxBuffer: 1024 * 1024 * 10 } // 10MB buffer + ); + + if (stderr && !stderr.includes('warning')) { + log.warn(`GitHubUpdater: Unzip stderr: ${stderr}`); + } + + // Check if Goose.app exists in the extracted content + const appPath = path.join(tempExtractDir, 'Goose.app'); + try { + await fs.access(appPath); + log.info(`GitHubUpdater: Found Goose.app at ${appPath}`); + } catch (error) { + log.error('GitHubUpdater: Goose.app not found in extracted content'); + throw new Error('Goose.app not found in extracted content'); + } + + // Move Goose.app to Downloads folder + const finalAppPath = path.join(downloadsDir, 'Goose.app'); + + // Remove existing Goose.app if it exists + try { + await fs.rm(finalAppPath, { recursive: true, force: true }); + } catch (e) { + // File might not exist, that's fine + } + + // Move the app to Downloads + log.info(`GitHubUpdater: Moving Goose.app to Downloads folder`); + await execAsync(`mv "${appPath}" "${finalAppPath}"`); + + // Verify the move was successful + try { + await fs.access(finalAppPath); + log.info(`GitHubUpdater: Successfully moved Goose.app to Downloads`); + } catch (error) { + log.error('GitHubUpdater: Failed to move Goose.app'); + throw new Error('Failed to move Goose.app to Downloads'); + } + + // Clean up temp directory and zip file + try { + await fs.rm(tempExtractDir, { recursive: true, force: true }); + await fs.unlink(downloadPath); + log.info(`GitHubUpdater: Cleaned up temporary files`); + } catch (cleanupError) { + log.warn(`GitHubUpdater: Failed to clean up temporary files: ${cleanupError}`); + } + + return { success: true, downloadPath: finalAppPath, extractedPath: downloadsDir }; + } catch (unzipError) { + log.error('GitHubUpdater: Error extracting update:', unzipError); + // Still return success for download, but note the extraction error + return { + success: true, + downloadPath, + error: `Downloaded successfully but extraction failed: ${unzipError instanceof Error ? unzipError.message : 'Unknown error'}` + }; + } + } catch (error) { + log.error('GitHubUpdater: Error downloading update:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } +} + +// Create singleton instance +export const githubUpdater = new GitHubUpdater(); From 04dac7280b91cb9db0b54dfd622b7d528fa9e7eb Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 11 Jun 2025 00:02:40 +0200 Subject: [PATCH 06/10] feat: add dynamic tray menu with update notifications - Add 'Update Available...' menu item when updates are ready - Dynamically update tray menu based on update status - Clean up console.log statements - Fix missing await statements in window creation - Add navigation to update settings from tray menu --- ui/desktop/package-lock.json | 4 +- ui/desktop/src/App.tsx | 10 ++- .../src/components/settings/SettingsView.tsx | 3 +- .../settings/app/AppSettingsSection.tsx | 21 ++++- ui/desktop/src/components/ui/button.tsx | 2 +- ui/desktop/src/main.ts | 24 ++---- ui/desktop/src/utils/autoUpdater.ts | 77 +++++++++++++++++++ 7 files changed, 114 insertions(+), 27 deletions(-) diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 27ccf095403a..f9fde21ad2ec 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "goose-app", - "version": "1.0.27", + "version": "1.0.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goose-app", - "version": "1.0.27", + "version": "1.0.20", "license": "Apache-2.0", "dependencies": { "@ai-sdk/openai": "^0.0.72", diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index d312764b9c1f..ba6fd56c9d6e 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -302,8 +302,14 @@ export default function App() { console.log('Setting up view change handler'); const handleSetView = (_event: IpcRendererEvent, ...args: unknown[]) => { const newView = args[0] as View; - console.log(`Received view change request to: ${newView}`); - setView(newView); + const section = args[1] as string | undefined; + console.log(`Received view change request to: ${newView}${section ? `, section: ${section}` : ''}`); + + if (section && newView === 'settings') { + setView(newView, { section }); + } else { + setView(newView); + } }; const urlParams = new URLSearchParams(window.location.search); const viewFromUrl = urlParams.get('view'); diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index 54d95b92279d..a85a24b6552a 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -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 */} {/* App Settings */} - +
diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 69182af648a3..d2e3c2226396 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -1,18 +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(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) => { @@ -109,7 +124,7 @@ export default function AppSettingsSection() { {/* Update Section */} -
+
diff --git a/ui/desktop/src/components/ui/button.tsx b/ui/desktop/src/components/ui/button.tsx index c2040c8657cc..4f7e21ee35dc 100644 --- a/ui/desktop/src/components/ui/button.tsx +++ b/ui/desktop/src/components/ui/button.tsx @@ -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', diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 49219cefb1d9..48406c38a0c2 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -35,7 +35,7 @@ import * as crypto from 'crypto'; import * as electron from 'electron'; import * as yaml from 'yaml'; import windowStateKeeper from 'electron-window-state'; -import { setupAutoUpdater, setTrayRef } from './utils/autoUpdater'; +import { setupAutoUpdater, setTrayRef, updateTrayMenu, getUpdateAvailable } from './utils/autoUpdater'; // Define temp directory for pasted images const gooseTempDir = path.join(app.getPath('temp'), 'goose-pasted-images'); @@ -340,7 +340,6 @@ const getVersion = () => { }; let [provider, model] = getGooseProvider(); -console.log('[main] Got provider and model:', { provider, model }); let sharingUrl = getSharingUrl(); @@ -357,8 +356,6 @@ let appConfig = { secretKey: generateSecretKey(), }; -console.log('[main] Created appConfig:', appConfig); - // Track windows by ID let windowCounter = 0; const windowMap = new Map(); @@ -513,8 +510,6 @@ const createChat = async ( `); }); - console.log('[main] Creating window with config:', windowConfig); - // Handle new window creation for links mainWindow.webContents.setWindowOpenHandler(({ url }) => { // Open all links in external browser @@ -553,7 +548,6 @@ const createChat = async ( } else { // In production, we need to use a proper file protocol URL with correct base path const indexPath = path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`); - console.log('Loading production path:', indexPath); mainWindow.loadFile(indexPath, { search: queryParams ? queryParams.slice(1) : undefined, }); @@ -611,14 +605,8 @@ const createTray = () => { // Set tray reference for auto-updater setTrayRef(tray); - const contextMenu = Menu.buildFromTemplate([ - { label: 'Show Window', click: showWindow }, - { type: 'separator' }, - { label: 'Quit', click: () => app.quit() }, - ]); - - tray.setToolTip('Goose'); - tray.setContextMenu(contextMenu); + // Initially build menu based on update status + updateTrayMenu(getUpdateAvailable()); // On Windows, clicking the tray icon should show the window if (process.platform === 'win32') { @@ -1125,7 +1113,7 @@ ipcMain.handle('get-allowed-extensions', async () => { const createNewWindow = async (app: App, dir?: string | null) => { const recentDirs = loadRecentDirs(); const openDir = dir || (recentDirs.length > 0 ? recentDirs[0] : undefined); - createChat(app, undefined, openDir); + return await createChat(app, undefined, openDir); }; const focusWindow = () => { @@ -1234,7 +1222,7 @@ app.whenReady().then(async () => { // Parse command line arguments const { dirPath } = parseArgs(); - createNewWindow(app, dirPath); + await createNewWindow(app, dirPath); // Get the existing menu const menu = Menu.getApplicationMenu(); @@ -1430,7 +1418,7 @@ app.whenReady().then(async () => { app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { - createChat(app); + createNewWindow(app); } }); diff --git a/ui/desktop/src/utils/autoUpdater.ts b/ui/desktop/src/utils/autoUpdater.ts index 58dd5743085f..3e6b02c696dc 100644 --- a/ui/desktop/src/utils/autoUpdater.ts +++ b/ui/desktop/src/utils/autoUpdater.ts @@ -382,6 +382,83 @@ function updateTrayIcon(hasUpdate: boolean) { icon.setTemplateImage(true); } trayRef.setImage(icon); + + // Update tray menu when icon changes + updateTrayMenu(hasUpdate); +} + +// Function to open settings and scroll to update section +function openUpdateSettings() { + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const mainWindow = windows[0]; + mainWindow.show(); + mainWindow.focus(); + // Send message to open settings and scroll to update section + mainWindow.webContents.send('set-view', 'settings', 'update'); + } +} + +// Export function to update tray menu +export function updateTrayMenu(hasUpdate: boolean) { + if (!trayRef) return; + + const { Menu, BrowserWindow, app } = require('electron'); + const menuItems: any[] = []; + + // Add update menu item if update is available + if (hasUpdate) { + menuItems.push({ + label: 'Update Available...', + click: openUpdateSettings + }); + } + + menuItems.push( + { label: 'Show Window', click: async () => { + const windows = BrowserWindow.getAllWindows(); + if (windows.length === 0) { + log.info('No windows are open, creating a new one...'); + // Get recent directories for the new window + const { loadRecentDirs } = require('./recentDirs'); + const { ipcMain } = require('electron'); + const recentDirs = loadRecentDirs(); + const openDir = recentDirs.length > 0 ? recentDirs[0] : null; + + // Emit event to create new window (handled in main.ts) + ipcMain.emit('create-chat-window', {}, undefined, openDir); + return; + } + + // Show all windows with offset + const initialOffsetX = 30; + const initialOffsetY = 30; + + windows.forEach((win, index) => { + const currentBounds = win.getBounds(); + const newX = currentBounds.x + initialOffsetX * index; + const newY = currentBounds.y + initialOffsetY * index; + + win.setBounds({ + x: newX, + y: newY, + width: currentBounds.width, + height: currentBounds.height, + }); + + if (!win.isVisible()) { + win.show(); + } + + win.focus(); + }); + }}, + { type: 'separator' }, + { label: 'Quit', click: () => app.quit() } + ); + + const contextMenu = Menu.buildFromTemplate(menuItems); + trayRef.setContextMenu(contextMenu); } // Export functions to manage tray reference From f77c7250a1c2177c346379c1285febd16e7aa2b6 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 11 Jun 2025 00:10:25 +0200 Subject: [PATCH 07/10] fix: resolve TypeScript errors and formatting issues - Fix implicit any types in autoUpdater.ts - Remove unused MenuItem import - Convert require statements to proper imports - Fix ESLint and formatting issues --- ui/desktop/src/App.tsx | 6 +- ui/desktop/src/main.ts | 7 +- ui/desktop/src/utils/autoUpdater.ts | 158 +++++++++++++++----------- ui/desktop/src/utils/githubUpdater.ts | 14 +-- 4 files changed, 106 insertions(+), 79 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index ba6fd56c9d6e..ace1228668a6 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -303,8 +303,10 @@ export default function App() { const handleSetView = (_event: IpcRendererEvent, ...args: unknown[]) => { const newView = args[0] as View; const section = args[1] as string | undefined; - console.log(`Received view change request to: ${newView}${section ? `, section: ${section}` : ''}`); - + console.log( + `Received view change request to: ${newView}${section ? `, section: ${section}` : ''}` + ); + if (section && newView === 'settings') { setView(newView, { section }); } else { diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 48406c38a0c2..831a977c9126 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -35,7 +35,12 @@ import * as crypto from 'crypto'; import * as electron from 'electron'; import * as yaml from 'yaml'; import windowStateKeeper from 'electron-window-state'; -import { setupAutoUpdater, setTrayRef, updateTrayMenu, getUpdateAvailable } from './utils/autoUpdater'; +import { + setupAutoUpdater, + setTrayRef, + updateTrayMenu, + getUpdateAvailable, +} from './utils/autoUpdater'; // Define temp directory for pasted images const gooseTempDir = path.join(app.getPath('temp'), 'goose-pasted-images'); diff --git a/ui/desktop/src/utils/autoUpdater.ts b/ui/desktop/src/utils/autoUpdater.ts index 3e6b02c696dc..b6724b562c2d 100644 --- a/ui/desktop/src/utils/autoUpdater.ts +++ b/ui/desktop/src/utils/autoUpdater.ts @@ -1,14 +1,31 @@ import { autoUpdater, UpdateInfo } from 'electron-updater'; -import { BrowserWindow, ipcMain, nativeImage, Tray, shell, app, dialog } from 'electron'; +import { + BrowserWindow, + ipcMain, + nativeImage, + Tray, + shell, + app, + dialog, + Menu, + MenuItemConstructorOptions, +} from 'electron'; import * as path from 'path'; import * as fs from 'fs/promises'; import log from './logger'; import { githubUpdater } from './githubUpdater'; +import { loadRecentDirs } from './recentDirs'; let updateAvailable = false; let trayRef: Tray | null = null; let isUsingGitHubFallback = false; -let githubUpdateInfo: { latestVersion?: string; downloadUrl?: string; releaseUrl?: string; downloadPath?: string; extractedPath?: string } = {}; +let githubUpdateInfo: { + latestVersion?: string; + downloadUrl?: string; + releaseUrl?: string; + downloadPath?: string; + extractedPath?: string; +} = {}; // Store update state let lastUpdateState: { updateAvailable: boolean; latestVersion?: string } | null = null; @@ -47,33 +64,36 @@ export function setupAutoUpdater(tray?: Tray) { ) { log.info('Using GitHub API fallback for startup update check...'); isUsingGitHubFallback = true; - - githubUpdater.checkForUpdates().then((result) => { - if (result.error) { - sendStatusToWindow('error', result.error); - } else if (result.updateAvailable) { - // Store GitHub update info - githubUpdateInfo = { - latestVersion: result.latestVersion, - downloadUrl: result.downloadUrl, - releaseUrl: result.releaseUrl, - }; - updateAvailable = true; - lastUpdateState = { updateAvailable: true, latestVersion: result.latestVersion }; - updateTrayIcon(true); - sendStatusToWindow('update-available', { version: result.latestVersion }); - } else { - updateAvailable = false; - lastUpdateState = { updateAvailable: false }; - updateTrayIcon(false); - sendStatusToWindow('update-not-available', { - version: autoUpdater.currentVersion.version, - }); - } - }).catch((fallbackError) => { - log.error('GitHub fallback also failed on startup:', fallbackError); - }); + githubUpdater + .checkForUpdates() + .then((result) => { + if (result.error) { + sendStatusToWindow('error', result.error); + } else if (result.updateAvailable) { + // Store GitHub update info + githubUpdateInfo = { + latestVersion: result.latestVersion, + downloadUrl: result.downloadUrl, + releaseUrl: result.releaseUrl, + }; + + updateAvailable = true; + lastUpdateState = { updateAvailable: true, latestVersion: result.latestVersion }; + updateTrayIcon(true); + sendStatusToWindow('update-available', { version: result.latestVersion }); + } else { + updateAvailable = false; + lastUpdateState = { updateAvailable: false }; + updateTrayIcon(false); + sendStatusToWindow('update-not-available', { + version: autoUpdater.currentVersion.version, + }); + } + }) + .catch((fallbackError) => { + log.error('GitHub fallback also failed on startup:', fallbackError); + }); } }); }, 5000); // Wait 5 seconds after app starts @@ -284,7 +304,7 @@ export function setupAutoUpdater(tray?: Tray) { try { // Use the stored extracted path if available, otherwise download path const updatePath = githubUpdateInfo.extractedPath || githubUpdateInfo.downloadPath; - + if (!updatePath) { throw new Error('Update file path not found. Please download the update first.'); } @@ -301,7 +321,7 @@ export function setupAutoUpdater(tray?: Tray) { const dialogResult = (await dialog.showMessageBox({ type: 'info', title: 'Update Ready', - message: isExtracted + message: isExtracted ? 'The update has been downloaded and extracted to your Downloads folder.' : 'The update has been downloaded to your Downloads folder.', detail: isExtracted @@ -382,7 +402,7 @@ function updateTrayIcon(hasUpdate: boolean) { icon.setTemplateImage(true); } trayRef.setImage(icon); - + // Update tray menu when icon changes updateTrayMenu(hasUpdate); } @@ -403,56 +423,56 @@ function openUpdateSettings() { export function updateTrayMenu(hasUpdate: boolean) { if (!trayRef) return; - const { Menu, BrowserWindow, app } = require('electron'); - const menuItems: any[] = []; - + const menuItems: MenuItemConstructorOptions[] = []; + // Add update menu item if update is available if (hasUpdate) { menuItems.push({ label: 'Update Available...', - click: openUpdateSettings + click: openUpdateSettings, }); } - + menuItems.push( - { label: 'Show Window', click: async () => { - const windows = BrowserWindow.getAllWindows(); - if (windows.length === 0) { - log.info('No windows are open, creating a new one...'); - // Get recent directories for the new window - const { loadRecentDirs } = require('./recentDirs'); - const { ipcMain } = require('electron'); - const recentDirs = loadRecentDirs(); - const openDir = recentDirs.length > 0 ? recentDirs[0] : null; - - // Emit event to create new window (handled in main.ts) - ipcMain.emit('create-chat-window', {}, undefined, openDir); - return; - } + { + label: 'Show Window', + click: async () => { + const windows = BrowserWindow.getAllWindows(); + if (windows.length === 0) { + log.info('No windows are open, creating a new one...'); + // Get recent directories for the new window + const recentDirs = loadRecentDirs(); + const openDir = recentDirs.length > 0 ? recentDirs[0] : null; + + // Emit event to create new window (handled in main.ts) + ipcMain.emit('create-chat-window', {}, undefined, openDir); + return; + } - // Show all windows with offset - const initialOffsetX = 30; - const initialOffsetY = 30; + // Show all windows with offset + const initialOffsetX = 30; + const initialOffsetY = 30; - windows.forEach((win, index) => { - const currentBounds = win.getBounds(); - const newX = currentBounds.x + initialOffsetX * index; - const newY = currentBounds.y + initialOffsetY * index; + windows.forEach((win: BrowserWindow, index: number) => { + const currentBounds = win.getBounds(); + const newX = currentBounds.x + initialOffsetX * index; + const newY = currentBounds.y + initialOffsetY * index; - win.setBounds({ - x: newX, - y: newY, - width: currentBounds.width, - height: currentBounds.height, - }); + win.setBounds({ + x: newX, + y: newY, + width: currentBounds.width, + height: currentBounds.height, + }); - if (!win.isVisible()) { - win.show(); - } + if (!win.isVisible()) { + win.show(); + } - win.focus(); - }); - }}, + win.focus(); + }); + }, + }, { type: 'separator' }, { label: 'Quit', click: () => app.quit() } ); diff --git a/ui/desktop/src/utils/githubUpdater.ts b/ui/desktop/src/utils/githubUpdater.ts index 6f0f99cefcea..4b491f93b300 100644 --- a/ui/desktop/src/utils/githubUpdater.ts +++ b/ui/desktop/src/utils/githubUpdater.ts @@ -165,13 +165,13 @@ export class GitHubUpdater { // Auto-unzip the downloaded file try { const tempExtractDir = path.join(downloadsDir, `temp-extract-${Date.now()}`); - + // Create temp extraction directory await fs.mkdir(tempExtractDir, { recursive: true }); // Use unzip command to extract log.info(`GitHubUpdater: Extracting ${fileName} to temp directory`); - + const { stderr } = await execAsync( `unzip -o "${downloadPath}" -d "${tempExtractDir}"`, { maxBuffer: 1024 * 1024 * 10 } // 10MB buffer @@ -193,7 +193,7 @@ export class GitHubUpdater { // Move Goose.app to Downloads folder const finalAppPath = path.join(downloadsDir, 'Goose.app'); - + // Remove existing Goose.app if it exists try { await fs.rm(finalAppPath, { recursive: true, force: true }); @@ -204,7 +204,7 @@ export class GitHubUpdater { // Move the app to Downloads log.info(`GitHubUpdater: Moving Goose.app to Downloads folder`); await execAsync(`mv "${appPath}" "${finalAppPath}"`); - + // Verify the move was successful try { await fs.access(finalAppPath); @@ -227,10 +227,10 @@ export class GitHubUpdater { } catch (unzipError) { log.error('GitHubUpdater: Error extracting update:', unzipError); // Still return success for download, but note the extraction error - return { - success: true, + return { + success: true, downloadPath, - error: `Downloaded successfully but extraction failed: ${unzipError instanceof Error ? unzipError.message : 'Unknown error'}` + error: `Downloaded successfully but extraction failed: ${unzipError instanceof Error ? unzipError.message : 'Unknown error'}`, }; } } catch (error) { From 90930a66c7c096627fa988f82441d8650ee768bf Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 12 Jun 2025 14:05:32 +0200 Subject: [PATCH 08/10] Replace exec with spawn for unzip and use fs.rename instead of mv - Replaced exec() with spawn() for the unzip command for better IPC security - Replaced shell mv command with built-in fs.rename() for file operations - Removed unused execAsync and promisify imports Addresses security concerns raised by @mrand-block in PR #2852 --- ui/desktop/src/utils/githubUpdater.ts | 31 +++++++++++++++++++-------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/ui/desktop/src/utils/githubUpdater.ts b/ui/desktop/src/utils/githubUpdater.ts index 4b491f93b300..7457ea696b14 100644 --- a/ui/desktop/src/utils/githubUpdater.ts +++ b/ui/desktop/src/utils/githubUpdater.ts @@ -3,12 +3,9 @@ import { compareVersions } from 'compare-versions'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; -import { exec } from 'child_process'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; import log from './logger'; -const execAsync = promisify(exec); - interface GitHubRelease { tag_name: string; name: string; @@ -172,10 +169,26 @@ export class GitHubUpdater { // Use unzip command to extract log.info(`GitHubUpdater: Extracting ${fileName} to temp directory`); - const { stderr } = await execAsync( - `unzip -o "${downloadPath}" -d "${tempExtractDir}"`, - { maxBuffer: 1024 * 1024 * 10 } // 10MB buffer - ); + const unzipProcess = spawn('unzip', ['-o', downloadPath, '-d', tempExtractDir]); + + let stderr = ''; + unzipProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + await new Promise((resolve, reject) => { + unzipProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Unzip process exited with code ${code}`)); + } + }); + + unzipProcess.on('error', (err) => { + reject(err); + }); + }); if (stderr && !stderr.includes('warning')) { log.warn(`GitHubUpdater: Unzip stderr: ${stderr}`); @@ -203,7 +216,7 @@ export class GitHubUpdater { // Move the app to Downloads log.info(`GitHubUpdater: Moving Goose.app to Downloads folder`); - await execAsync(`mv "${appPath}" "${finalAppPath}"`); + await fs.rename(appPath, finalAppPath); // Verify the move was successful try { From 44fd2f98595011a07004d1c988bd3c1c1b49be36 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 12 Jun 2025 14:09:45 +0200 Subject: [PATCH 09/10] chore: fix prettier formatting in githubUpdater.ts --- ui/desktop/src/utils/githubUpdater.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/utils/githubUpdater.ts b/ui/desktop/src/utils/githubUpdater.ts index 7457ea696b14..c6d4dd4fddb6 100644 --- a/ui/desktop/src/utils/githubUpdater.ts +++ b/ui/desktop/src/utils/githubUpdater.ts @@ -170,7 +170,7 @@ export class GitHubUpdater { log.info(`GitHubUpdater: Extracting ${fileName} to temp directory`); const unzipProcess = spawn('unzip', ['-o', downloadPath, '-d', tempExtractDir]); - + let stderr = ''; unzipProcess.stderr.on('data', (data) => { stderr += data.toString(); @@ -184,7 +184,7 @@ export class GitHubUpdater { reject(new Error(`Unzip process exited with code ${code}`)); } }); - + unzipProcess.on('error', (err) => { reject(err); }); From 54d6045baec688ee89a7357c8dd58d276a85df26 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:10:44 +0200 Subject: [PATCH 10/10] Update ui/desktop/src/utils/autoUpdater.ts Co-authored-by: Bradley Axen --- ui/desktop/src/utils/autoUpdater.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/desktop/src/utils/autoUpdater.ts b/ui/desktop/src/utils/autoUpdater.ts index b6724b562c2d..d61922bf7f32 100644 --- a/ui/desktop/src/utils/autoUpdater.ts +++ b/ui/desktop/src/utils/autoUpdater.ts @@ -48,6 +48,11 @@ export function setupAutoUpdater(tray?: Tray) { autoUpdater.autoDownload = false; // We'll trigger downloads manually autoUpdater.autoInstallOnAppQuit = true; + // Enable updates in development mode for testing + if (process.env.ENABLE_DEV_UPDATES === 'true') { + autoUpdater.forceDevUpdateConfig = true; + } + // Set logger autoUpdater.logger = log;