feat(desktop): Add auto-update functionality to Goose desktop app (#2852)

Co-authored-by: jack <jack@deck.local>
Co-authored-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
jack
2025-06-12 20:25:44 +02:00
committed by GitHub
parent ff80c2f44a
commit 37812fc76a
15 changed files with 1264 additions and 32 deletions

View File

@@ -44,6 +44,19 @@ if (process.env['APPLE_ID'] === undefined) {
module.exports = { module.exports = {
packagerConfig: cfg, packagerConfig: cfg,
rebuildConfig: {}, rebuildConfig: {},
publishers: [
{
name: '@electron-forge/publisher-github',
config: {
repository: {
owner: 'block',
name: 'goose'
},
prerelease: false,
draft: true
}
}
],
makers: [ makers: [
{ {
name: '@electron-forge/maker-zip', name: '@electron-forge/maker-zip',

View File

@@ -1,12 +1,12 @@
{ {
"name": "goose-app", "name": "goose-app",
"version": "1.0.27", "version": "1.0.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "goose-app", "name": "goose-app",
"version": "1.0.27", "version": "1.0.20",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^0.0.72", "@ai-sdk/openai": "^0.0.72",
@@ -30,11 +30,13 @@
"ai": "^3.4.33", "ai": "^3.4.33",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"compare-versions": "^6.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cronstrue": "^2.48.0", "cronstrue": "^2.48.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"electron-log": "^5.2.2", "electron-log": "^5.2.2",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"express": "^4.21.1", "express": "^4.21.1",
"framer-motion": "^11.11.11", "framer-motion": "^11.11.11",
@@ -5048,7 +5050,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-hidden": { "node_modules/aria-hidden": {
@@ -5563,6 +5564,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -6140,6 +6154,12 @@
"node": ">=0.10.0" "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": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -7139,6 +7159,22 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/electron-window-state": {
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/electron-window-state/-/electron-window-state-5.0.3.tgz", "resolved": "https://registry.npmjs.org/electron-window-state/-/electron-window-state-5.0.3.tgz",
@@ -8512,7 +8548,6 @@
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@@ -8966,7 +9001,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"devOptional": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphemer": { "node_modules/graphemer": {
@@ -10139,7 +10173,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -10254,7 +10287,6 @@
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
@@ -10299,6 +10331,12 @@
"json-buffer": "3.0.1" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -10703,6 +10741,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lodash.get": {
"version": "4.4.2", "version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
@@ -10711,6 +10755,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -14562,6 +14613,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -14581,7 +14638,6 @@
"version": "7.7.2", "version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -15825,6 +15881,12 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/tinyexec": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
@@ -16290,7 +16352,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"

View File

@@ -104,11 +104,13 @@
"ai": "^3.4.33", "ai": "^3.4.33",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"compare-versions": "^6.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cronstrue": "^2.48.0", "cronstrue": "^2.48.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"electron-log": "^5.2.2", "electron-log": "^5.2.2",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"express": "^4.21.1", "express": "^4.21.1",
"framer-motion": "^11.11.11", "framer-motion": "^11.11.11",

View File

@@ -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);

View File

@@ -302,8 +302,16 @@ export default function App() {
console.log('Setting up view change handler'); console.log('Setting up view change handler');
const handleSetView = (_event: IpcRendererEvent, ...args: unknown[]) => { const handleSetView = (_event: IpcRendererEvent, ...args: unknown[]) => {
const newView = args[0] as View; const newView = args[0] as View;
console.log(`Received view change request to: ${newView}`); const section = args[1] as string | undefined;
setView(newView); 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 urlParams = new URLSearchParams(window.location.search);
const viewFromUrl = urlParams.get('view'); const viewFromUrl = urlParams.get('view');

View File

@@ -14,6 +14,7 @@ import MoreMenuLayout from '../more_menu/MoreMenuLayout';
export type SettingsViewOptions = { export type SettingsViewOptions = {
deepLinkConfig?: ExtensionConfig; deepLinkConfig?: ExtensionConfig;
showEnvVars?: boolean; showEnvVars?: boolean;
section?: string;
}; };
export default function SettingsView({ export default function SettingsView({
@@ -55,7 +56,7 @@ export default function SettingsView({
{/* Tool Selection Strategy */} {/* Tool Selection Strategy */}
<ToolSelectionStrategySection setView={setView} /> <ToolSelectionStrategySection setView={setView} />
{/* App Settings */} {/* App Settings */}
<AppSettingsSection /> <AppSettingsSection scrollToSection={viewOptions.section} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,17 +1,33 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Switch } from '../../ui/switch'; 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 [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true);
const [dockIconEnabled, setDockIconEnabled] = useState(true); const [dockIconEnabled, setDockIconEnabled] = useState(true);
const [isMacOS, setIsMacOS] = useState(false); const [isMacOS, setIsMacOS] = useState(false);
const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false); const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false);
const updateSectionRef = useRef<HTMLDivElement>(null);
// Check if running on macOS // Check if running on macOS
useEffect(() => { useEffect(() => {
setIsMacOS(window.electron.platform === 'darwin'); 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 // Load menu bar and dock icon states
useEffect(() => { useEffect(() => {
window.electron.getMenuBarIconState().then((enabled) => { window.electron.getMenuBarIconState().then((enabled) => {
@@ -106,6 +122,11 @@ export default function AppSettingsSection() {
</div> </div>
)} )}
</div> </div>
{/* Update Section */}
<div ref={updateSectionRef} className="mt-8 pt-8 border-t border-gray-200">
<UpdateSection />
</div>
</div> </div>
</section> </section>
); );

View File

@@ -0,0 +1,270 @@
import { useState, useEffect } from 'react';
import { Button } from '../../ui/button';
import { Loader2, Download, CheckCircle, AlertCircle } from 'lucide-react';
type UpdateStatus =
| 'idle'
| 'checking'
| 'downloading'
| 'installing'
| 'success'
| 'error'
| 'ready';
interface UpdateInfo {
currentVersion: string;
latestVersion?: string;
isUpdateAvailable?: boolean;
error?: string;
}
interface UpdateEventData {
version?: string;
percent?: number;
}
export default function UpdateSection() {
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const [updateInfo, setUpdateInfo] = useState<UpdateInfo>({
currentVersion: '',
});
const [progress, setProgress] = useState<number>(0);
useEffect(() => {
// Get current version on mount
const currentVersion = window.electron.getVersion();
setUpdateInfo((prev) => ({ ...prev, currentVersion }));
// Check if there's already an update state from the auto-check
window.electron.getUpdateState().then((state) => {
if (state) {
console.log('Found existing update state:', state);
setUpdateInfo((prev) => ({
...prev,
isUpdateAvailable: state.updateAvailable,
latestVersion: state.latestVersion,
}));
}
});
// Listen for updater events
window.electron.onUpdaterEvent((event) => {
console.log('Updater event:', event);
switch (event.event) {
case 'checking-for-update':
setUpdateStatus('checking');
break;
case 'update-available':
setUpdateStatus('idle');
setUpdateInfo((prev) => ({
...prev,
latestVersion: (event.data as UpdateEventData)?.version,
isUpdateAvailable: true,
}));
break;
case 'update-not-available':
setUpdateStatus('idle');
setUpdateInfo((prev) => ({
...prev,
isUpdateAvailable: false,
}));
break;
case 'download-progress':
setUpdateStatus('downloading');
setProgress((event.data as UpdateEventData)?.percent || 0);
break;
case 'update-downloaded':
setUpdateStatus('ready');
setProgress(100);
break;
case 'error':
setUpdateStatus('error');
setUpdateInfo((prev) => ({
...prev,
error: String(event.data || 'An error occurred'),
}));
setTimeout(() => setUpdateStatus('idle'), 5000);
break;
}
});
}, []);
const checkForUpdates = async () => {
setUpdateStatus('checking');
setProgress(0);
try {
const result = await window.electron.checkForUpdates();
if (result.error) {
throw new Error(result.error);
}
// If we successfully checked and no update is available, show success
if (!result.error && updateInfo.isUpdateAvailable === false) {
setUpdateStatus('success');
setTimeout(() => setUpdateStatus('idle'), 3000);
}
// The actual status will be handled by the updater events
} catch (error) {
console.error('Error checking for updates:', error);
setUpdateInfo((prev) => ({
...prev,
error: error instanceof Error ? error.message : 'Failed to check for updates',
}));
setUpdateStatus('error');
setTimeout(() => setUpdateStatus('idle'), 5000);
}
};
const downloadAndInstallUpdate = async () => {
setUpdateStatus('downloading');
setProgress(0);
try {
const result = await window.electron.downloadUpdate();
if (!result.success) {
throw new Error(result.error || 'Failed to download update');
}
// The download progress and completion will be handled by updater events
} catch (error) {
console.error('Error downloading update:', error);
setUpdateInfo((prev) => ({
...prev,
error: error instanceof Error ? error.message : 'Failed to download update',
}));
setUpdateStatus('error');
setTimeout(() => setUpdateStatus('idle'), 5000);
}
};
const installUpdate = () => {
setUpdateStatus('installing');
// This will quit the app and install the update
window.electron.installUpdate();
};
const getStatusMessage = () => {
switch (updateStatus) {
case 'checking':
return 'Checking for updates...';
case 'downloading':
return `Downloading update... ${Math.round(progress)}%`;
case 'installing':
return 'Installing update...';
case 'ready':
return 'Update downloaded and ready to install!';
case 'success':
return updateInfo.isUpdateAvailable === false
? 'You are running the latest version!'
: 'Update available!';
case 'error':
return updateInfo.error || 'An error occurred';
default:
if (updateInfo.isUpdateAvailable) {
return `Version ${updateInfo.latestVersion} is available`;
}
return '';
}
};
const getStatusIcon = () => {
switch (updateStatus) {
case 'checking':
case 'downloading':
case 'installing':
return <Loader2 className="w-4 h-4 animate-spin" />;
case 'success':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'error':
return <AlertCircle className="w-4 h-4 text-red-500" />;
case 'ready':
return <CheckCircle className="w-4 h-4 text-blue-500" />;
default:
return updateInfo.isUpdateAvailable ? <Download className="w-4 h-4" /> : null;
}
};
return (
<div>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-medium text-textStandard">Updates</h2>
</div>
<div className="pb-8">
<p className="text-sm text-textStandard mb-6">
Current version: {updateInfo.currentVersion || 'Loading...'}
{updateInfo.latestVersion && updateInfo.isUpdateAvailable && (
<span className="text-textSubtle"> {updateInfo.latestVersion} available</span>
)}
{updateInfo.currentVersion && updateInfo.isUpdateAvailable === false && ' (up to date)'}
</p>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Button
onClick={checkForUpdates}
disabled={updateStatus !== 'idle' && updateStatus !== 'error'}
variant="outline"
size="sm"
className="text-xs"
>
Check for Updates
</Button>
{updateInfo.isUpdateAvailable && updateStatus === 'idle' && (
<Button
onClick={downloadAndInstallUpdate}
variant="default"
size="sm"
className="text-xs"
>
<Download className="w-3 h-3 mr-1" />
Download Update
</Button>
)}
{updateStatus === 'ready' && (
<Button onClick={installUpdate} variant="default" size="sm" className="text-xs">
Install & Restart
</Button>
)}
</div>
{getStatusMessage() && (
<div className="flex items-center gap-2 text-xs text-textSubtle">
{getStatusIcon()}
<span>{getStatusMessage()}</span>
</div>
)}
{updateStatus === 'downloading' && (
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* Update information */}
{updateInfo.isUpdateAvailable && (
<div className="text-xs text-textSubtle mt-4 space-y-1">
<p>Update will be downloaded and automatically extracted to your Downloads folder.</p>
<p className="text-xs text-amber-600">
After download, move the Goose app to /Applications to complete the update.
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -12,7 +12,7 @@ const buttonVariants = cva(
default: 'bg-gray-800 text-white rounded-full px-6 py-2 hover:bg-gray-700', 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', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 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', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -35,6 +35,12 @@ import * as crypto from 'crypto';
import * as electron from 'electron'; import * as electron from 'electron';
import * as yaml from 'yaml'; import * as yaml from 'yaml';
import windowStateKeeper from 'electron-window-state'; import windowStateKeeper from 'electron-window-state';
import {
setupAutoUpdater,
setTrayRef,
updateTrayMenu,
getUpdateAvailable,
} from './utils/autoUpdater';
// Define temp directory for pasted images // Define temp directory for pasted images
const gooseTempDir = path.join(app.getPath('temp'), 'goose-pasted-images'); const gooseTempDir = path.join(app.getPath('temp'), 'goose-pasted-images');
@@ -339,7 +345,6 @@ const getVersion = () => {
}; };
let [provider, model] = getGooseProvider(); let [provider, model] = getGooseProvider();
console.log('[main] Got provider and model:', { provider, model });
let sharingUrl = getSharingUrl(); let sharingUrl = getSharingUrl();
@@ -356,8 +361,6 @@ let appConfig = {
secretKey: generateSecretKey(), secretKey: generateSecretKey(),
}; };
console.log('[main] Created appConfig:', appConfig);
// Track windows by ID // Track windows by ID
let windowCounter = 0; let windowCounter = 0;
const windowMap = new Map<number, BrowserWindow>(); const windowMap = new Map<number, BrowserWindow>();
@@ -512,8 +515,6 @@ const createChat = async (
`); `);
}); });
console.log('[main] Creating window with config:', windowConfig);
// Handle new window creation for links // Handle new window creation for links
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
// Open all links in external browser // Open all links in external browser
@@ -552,7 +553,6 @@ const createChat = async (
} else { } else {
// In production, we need to use a proper file protocol URL with correct base path // 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`); const indexPath = path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`);
console.log('Loading production path:', indexPath);
mainWindow.loadFile(indexPath, { mainWindow.loadFile(indexPath, {
search: queryParams ? queryParams.slice(1) : undefined, search: queryParams ? queryParams.slice(1) : undefined,
}); });
@@ -607,14 +607,11 @@ const createTray = () => {
tray = new Tray(iconPath); tray = new Tray(iconPath);
const contextMenu = Menu.buildFromTemplate([ // Set tray reference for auto-updater
{ label: 'Show Window', click: showWindow }, setTrayRef(tray);
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
]);
tray.setToolTip('Goose'); // Initially build menu based on update status
tray.setContextMenu(contextMenu); updateTrayMenu(getUpdateAvailable());
// On Windows, clicking the tray icon should show the window // On Windows, clicking the tray icon should show the window
if (process.platform === 'win32') { if (process.platform === 'win32') {
@@ -1121,7 +1118,7 @@ ipcMain.handle('get-allowed-extensions', async () => {
const createNewWindow = async (app: App, dir?: string | null) => { const createNewWindow = async (app: App, dir?: string | null) => {
const recentDirs = loadRecentDirs(); const recentDirs = loadRecentDirs();
const openDir = dir || (recentDirs.length > 0 ? recentDirs[0] : undefined); const openDir = dir || (recentDirs.length > 0 ? recentDirs[0] : undefined);
createChat(app, undefined, openDir); return await createChat(app, undefined, openDir);
}; };
const focusWindow = () => { const focusWindow = () => {
@@ -1159,6 +1156,9 @@ const registerGlobalHotkey = (accelerator: string) => {
}; };
app.whenReady().then(async () => { app.whenReady().then(async () => {
// Setup auto-updater
setupAutoUpdater();
// Add CSP headers to all sessions // Add CSP headers to all sessions
session.defaultSession.webRequest.onHeadersReceived((details, callback) => { session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({ callback({
@@ -1173,7 +1173,7 @@ app.whenReady().then(async () => {
// Images from our app and data: URLs (for base64 images) // Images from our app and data: URLs (for base64 images)
"img-src 'self' data: https:;" + "img-src 'self' data: https:;" +
// Connect to our local API and specific external services // 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 // Don't allow any plugins
"object-src 'none';" + "object-src 'none';" +
// Don't allow any frames // Don't allow any frames
@@ -1227,7 +1227,7 @@ app.whenReady().then(async () => {
// Parse command line arguments // Parse command line arguments
const { dirPath } = parseArgs(); const { dirPath } = parseArgs();
createNewWindow(app, dirPath); await createNewWindow(app, dirPath);
// Get the existing menu // Get the existing menu
const menu = Menu.getApplicationMenu(); const menu = Menu.getApplicationMenu();
@@ -1423,7 +1423,7 @@ app.whenReady().then(async () => {
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { 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); 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();
});
}); });
/** /**

View File

@@ -29,6 +29,11 @@ interface SaveDataUrlResponse {
const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}'); const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}');
interface UpdaterEvent {
event: string;
data?: unknown;
}
// Define the API types in a single place // Define the API types in a single place
type ElectronAPI = { type ElectronAPI = {
platform: string; platform: string;
@@ -76,6 +81,14 @@ type ElectronAPI = {
deleteTempFile: (filePath: string) => void; deleteTempFile: (filePath: string) => void;
// Function to serve temp images // Function to serve temp images
getTempImage: (filePath: string) => Promise<string | null>; getTempImage: (filePath: string) => Promise<string | null>;
// 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 = { type AppConfigAPI = {
@@ -149,6 +162,27 @@ const electronAPI: ElectronAPI = {
getTempImage: (filePath: string): Promise<string | null> => { getTempImage: (filePath: string): Promise<string | null> => {
return ipcRenderer.invoke('get-temp-image', filePath); 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 = { const appConfigAPI: AppConfigAPI = {

View File

@@ -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;
}

View File

@@ -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<UpdateCheckResult> {
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<void>((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();