diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index efaa79e3..5dc22339 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 8114bf59..f9fde21a 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", @@ -30,11 +30,13 @@ "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", "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 +5050,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 +5564,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", @@ -6140,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", @@ -7139,6 +7159,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 +8548,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 +9001,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 +10173,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 +10287,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 +10331,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 +10741,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 +10755,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 +14613,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 +14638,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 +15881,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 +16352,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 f83c4f94..2646f2b3 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -104,11 +104,13 @@ "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", "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/scripts/generate-update-icon.js b/ui/desktop/scripts/generate-update-icon.js new file mode 100644 index 00000000..edf533ce --- /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/App.tsx b/ui/desktop/src/App.tsx index d312764b..ace12286 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -302,8 +302,16 @@ 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 54d95b92..a85a24b6 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 317b9e2b..d2e3c222 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -1,17 +1,33 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Switch } from '../../ui/switch'; +import UpdateSection from './UpdateSection'; -export default function AppSettingsSection() { +interface AppSettingsSectionProps { + scrollToSection?: string; +} + +export default function AppSettingsSection({ scrollToSection }: AppSettingsSectionProps) { const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true); const [dockIconEnabled, setDockIconEnabled] = useState(true); const [isMacOS, setIsMacOS] = useState(false); const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false); + const updateSectionRef = useRef(null); // Check if running on macOS useEffect(() => { setIsMacOS(window.electron.platform === 'darwin'); }, []); + // Handle scrolling to update section + useEffect(() => { + if (scrollToSection === 'update' && updateSectionRef.current) { + // Use a timeout to ensure the DOM is ready + setTimeout(() => { + updateSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + } + }, [scrollToSection]); + // Load menu bar and dock icon states useEffect(() => { window.electron.getMenuBarIconState().then((enabled) => { @@ -106,6 +122,11 @@ export default function AppSettingsSection() { )} + + {/* 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 00000000..3d0639a3 --- /dev/null +++ b/ui/desktop/src/components/settings/app/UpdateSection.tsx @@ -0,0 +1,270 @@ +import { useState, useEffect } from 'react'; +import { Button } from '../../ui/button'; +import { Loader2, Download, CheckCircle, AlertCircle } from 'lucide-react'; + +type UpdateStatus = + | 'idle' + | 'checking' + | 'downloading' + | 'installing' + | 'success' + | 'error' + | 'ready'; + +interface UpdateInfo { + currentVersion: string; + latestVersion?: string; + isUpdateAvailable?: boolean; + error?: string; +} + +interface UpdateEventData { + version?: string; + percent?: number; +} + +export default function UpdateSection() { + const [updateStatus, setUpdateStatus] = useState('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 })); + + // Check if there's already an update state from the auto-check + window.electron.getUpdateState().then((state) => { + if (state) { + console.log('Found existing update state:', state); + setUpdateInfo((prev) => ({ + ...prev, + isUpdateAvailable: state.updateAvailable, + latestVersion: state.latestVersion, + })); + } + }); + + // Listen for updater events + window.electron.onUpdaterEvent((event) => { + console.log('Updater event:', event); + + switch (event.event) { + case 'checking-for-update': + setUpdateStatus('checking'); + break; + + case 'update-available': + setUpdateStatus('idle'); + setUpdateInfo((prev) => ({ + ...prev, + latestVersion: (event.data as UpdateEventData)?.version, + isUpdateAvailable: true, + })); + break; + + case 'update-not-available': + setUpdateStatus('idle'); + setUpdateInfo((prev) => ({ + ...prev, + isUpdateAvailable: false, + })); + break; + + case 'download-progress': + setUpdateStatus('downloading'); + setProgress((event.data as UpdateEventData)?.percent || 0); + break; + + case 'update-downloaded': + setUpdateStatus('ready'); + setProgress(100); + break; + + case 'error': + setUpdateStatus('error'); + setUpdateInfo((prev) => ({ + ...prev, + error: String(event.data || 'An error occurred'), + })); + setTimeout(() => setUpdateStatus('idle'), 5000); + break; + } + }); + }, []); + + const checkForUpdates = async () => { + setUpdateStatus('checking'); + setProgress(0); + + try { + const result = await window.electron.checkForUpdates(); + + if (result.error) { + throw new Error(result.error); + } + + // If we successfully checked and no update is available, show success + if (!result.error && updateInfo.isUpdateAvailable === false) { + setUpdateStatus('success'); + setTimeout(() => setUpdateStatus('idle'), 3000); + } + // The actual status will be handled by the updater events + } catch (error) { + console.error('Error checking for updates:', error); + setUpdateInfo((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to check for updates', + })); + setUpdateStatus('error'); + setTimeout(() => setUpdateStatus('idle'), 5000); + } + }; + + const downloadAndInstallUpdate = async () => { + setUpdateStatus('downloading'); + setProgress(0); + + try { + const result = await window.electron.downloadUpdate(); + + if (!result.success) { + throw new Error(result.error || 'Failed to download update'); + } + + // The download progress and completion will be handled by updater events + } catch (error) { + console.error('Error downloading update:', error); + setUpdateInfo((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to download update', + })); + setUpdateStatus('error'); + setTimeout(() => setUpdateStatus('idle'), 5000); + } + }; + + const installUpdate = () => { + setUpdateStatus('installing'); + // This will quit the app and install the update + window.electron.installUpdate(); + }; + + const getStatusMessage = () => { + switch (updateStatus) { + case 'checking': + return 'Checking for updates...'; + case 'downloading': + return `Downloading update... ${Math.round(progress)}%`; + case 'installing': + return 'Installing update...'; + case 'ready': + return 'Update downloaded and ready to install!'; + case 'success': + return updateInfo.isUpdateAvailable === false + ? 'You are running the latest version!' + : 'Update available!'; + case 'error': + return updateInfo.error || 'An error occurred'; + default: + if (updateInfo.isUpdateAvailable) { + return `Version ${updateInfo.latestVersion} is available`; + } + return ''; + } + }; + + const getStatusIcon = () => { + switch (updateStatus) { + case 'checking': + case 'downloading': + case 'installing': + return ; + case 'success': + return ; + case 'error': + return ; + case 'ready': + return ; + default: + return updateInfo.isUpdateAvailable ? : null; + } + }; + + return ( +
+
+

Updates

+
+
+

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

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

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

+

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

+
+ )} +
+
+
+ ); +} diff --git a/ui/desktop/src/components/ui/button.tsx b/ui/desktop/src/components/ui/button.tsx index c2040c86..4f7e21ee 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/images/iconTemplateUpdate.png b/ui/desktop/src/images/iconTemplateUpdate.png new file mode 100644 index 00000000..a3f8b6fb Binary files /dev/null and b/ui/desktop/src/images/iconTemplateUpdate.png differ diff --git a/ui/desktop/src/images/iconTemplateUpdate@2x.png b/ui/desktop/src/images/iconTemplateUpdate@2x.png new file mode 100644 index 00000000..deb01680 Binary files /dev/null and b/ui/desktop/src/images/iconTemplateUpdate@2x.png differ diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index ec87055a..831a977c 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -35,6 +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'; // Define temp directory for pasted images const gooseTempDir = path.join(app.getPath('temp'), 'goose-pasted-images'); @@ -339,7 +345,6 @@ const getVersion = () => { }; let [provider, model] = getGooseProvider(); -console.log('[main] Got provider and model:', { provider, model }); let sharingUrl = getSharingUrl(); @@ -356,8 +361,6 @@ let appConfig = { secretKey: generateSecretKey(), }; -console.log('[main] Created appConfig:', appConfig); - // Track windows by ID let windowCounter = 0; const windowMap = new Map(); @@ -512,8 +515,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 @@ -552,7 +553,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, }); @@ -607,14 +607,11 @@ const createTray = () => { tray = new Tray(iconPath); - const contextMenu = Menu.buildFromTemplate([ - { label: 'Show Window', click: showWindow }, - { type: 'separator' }, - { label: 'Quit', click: () => app.quit() }, - ]); + // Set tray reference for auto-updater + setTrayRef(tray); - 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') { @@ -1121,7 +1118,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 = () => { @@ -1159,6 +1156,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 +1173,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 @@ -1227,7 +1227,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(); @@ -1423,7 +1423,7 @@ app.whenReady().then(async () => { app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { - createChat(app); + createNewWindow(app); } }); @@ -1598,6 +1598,17 @@ app.whenReady().then(async () => { console.error('Error opening URL in Chrome:', 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 13674f88..d79f68a5 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -29,6 +29,11 @@ interface SaveDataUrlResponse { 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; @@ -76,6 +81,14 @@ type ElectronAPI = { deleteTempFile: (filePath: string) => void; // Function to serve temp images getTempImage: (filePath: string) => Promise; + // Update-related functions + getVersion: () => string; + checkForUpdates: () => Promise<{ updateInfo: unknown; error: string | null }>; + downloadUpdate: () => Promise<{ success: boolean; error: string | null }>; + installUpdate: () => void; + restartApp: () => void; + onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => void; + getUpdateState: () => Promise<{ updateAvailable: boolean; latestVersion?: string } | null>; }; type AppConfigAPI = { @@ -149,6 +162,27 @@ const electronAPI: ElectronAPI = { getTempImage: (filePath: string): Promise => { return ipcRenderer.invoke('get-temp-image', filePath); }, + getVersion: (): string => { + return config.GOOSE_VERSION || ipcRenderer.sendSync('get-app-version') || ''; + }, + checkForUpdates: (): Promise<{ updateInfo: unknown; error: string | null }> => { + return ipcRenderer.invoke('check-for-updates'); + }, + 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)); + }, + 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 new file mode 100644 index 00000000..d61922bf --- /dev/null +++ b/ui/desktop/src/utils/autoUpdater.ts @@ -0,0 +1,498 @@ +import { autoUpdater, UpdateInfo } from 'electron-updater'; +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; +} = {}; + +// 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', + }); + + // Configure auto-updater settings + 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; + + // 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); + // 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 + + // 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); + updateAvailable = true; + lastUpdateState = { updateAvailable: true, latestVersion: info.version }; + updateTrayIcon(true); + sendStatusToWindow('update-available', info); + }); + + 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', async (err) => { + log.error('Error in auto-updater:', err); + + // 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); + } + }); + + 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 { + // 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, + }; + } catch (error) { + log.error('Error checking for updates:', error); + + // 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, + }; + } catch (fallbackError) { + log.error('GitHub fallback also failed:', fallbackError); + return { + updateInfo: null, + error: 'Unable to check for updates. Please check your internet connection.', + }; + } + } + + return { + updateInfo: null, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + ipcMain.handle('download-update', async () => { + try { + 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', + }; + } + }); + + 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 { + 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); + }); +} + +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); + + // 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 menuItems: MenuItemConstructorOptions[] = []; + + // 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 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: 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, + }); + + 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 +export function setTrayRef(tray: Tray) { + trayRef = tray; + // Update icon based on current update status + updateTrayIcon(updateAvailable); +} + +export function getUpdateAvailable(): boolean { + return updateAvailable; +} diff --git a/ui/desktop/src/utils/githubUpdater.ts b/ui/desktop/src/utils/githubUpdater.ts new file mode 100644 index 00000000..c6d4dd4f --- /dev/null +++ b/ui/desktop/src/utils/githubUpdater.ts @@ -0,0 +1,260 @@ +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 { spawn } from 'child_process'; +import log from './logger'; + +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 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}`); + } + + // 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 fs.rename(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();