mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-21 16:14:21 +01:00
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:
@@ -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',
|
||||
|
||||
79
ui/desktop/package-lock.json
generated
79
ui/desktop/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
53
ui/desktop/scripts/generate-update-icon.js
Normal file
53
ui/desktop/scripts/generate-update-icon.js
Normal 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);
|
||||
@@ -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');
|
||||
|
||||
@@ -14,6 +14,7 @@ import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
||||
export type SettingsViewOptions = {
|
||||
deepLinkConfig?: ExtensionConfig;
|
||||
showEnvVars?: boolean;
|
||||
section?: string;
|
||||
};
|
||||
|
||||
export default function SettingsView({
|
||||
@@ -55,7 +56,7 @@ export default function SettingsView({
|
||||
{/* Tool Selection Strategy */}
|
||||
<ToolSelectionStrategySection setView={setView} />
|
||||
{/* App Settings */}
|
||||
<AppSettingsSection />
|
||||
<AppSettingsSection scrollToSection={viewOptions.section} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Switch } from '../../ui/switch';
|
||||
import UpdateSection from './UpdateSection';
|
||||
|
||||
export default function AppSettingsSection() {
|
||||
interface AppSettingsSectionProps {
|
||||
scrollToSection?: string;
|
||||
}
|
||||
|
||||
export default function AppSettingsSection({ scrollToSection }: AppSettingsSectionProps) {
|
||||
const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true);
|
||||
const [dockIconEnabled, setDockIconEnabled] = useState(true);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false);
|
||||
const updateSectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check if running on macOS
|
||||
useEffect(() => {
|
||||
setIsMacOS(window.electron.platform === 'darwin');
|
||||
}, []);
|
||||
|
||||
// Handle scrolling to update section
|
||||
useEffect(() => {
|
||||
if (scrollToSection === 'update' && updateSectionRef.current) {
|
||||
// Use a timeout to ensure the DOM is ready
|
||||
setTimeout(() => {
|
||||
updateSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 100);
|
||||
}
|
||||
}, [scrollToSection]);
|
||||
|
||||
// Load menu bar and dock icon states
|
||||
useEffect(() => {
|
||||
window.electron.getMenuBarIconState().then((enabled) => {
|
||||
@@ -106,6 +122,11 @@ export default function AppSettingsSection() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update Section */}
|
||||
<div ref={updateSectionRef} className="mt-8 pt-8 border-t border-gray-200">
|
||||
<UpdateSection />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
270
ui/desktop/src/components/settings/app/UpdateSection.tsx
Normal file
270
ui/desktop/src/components/settings/app/UpdateSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
BIN
ui/desktop/src/images/iconTemplateUpdate.png
Normal file
BIN
ui/desktop/src/images/iconTemplateUpdate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 630 B |
BIN
ui/desktop/src/images/iconTemplateUpdate@2x.png
Normal file
BIN
ui/desktop/src/images/iconTemplateUpdate@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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<number, BrowserWindow>();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<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 = {
|
||||
@@ -149,6 +162,27 @@ const electronAPI: ElectronAPI = {
|
||||
getTempImage: (filePath: string): Promise<string | null> => {
|
||||
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 = {
|
||||
|
||||
498
ui/desktop/src/utils/autoUpdater.ts
Normal file
498
ui/desktop/src/utils/autoUpdater.ts
Normal 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;
|
||||
}
|
||||
260
ui/desktop/src/utils/githubUpdater.ts
Normal file
260
ui/desktop/src/utils/githubUpdater.ts
Normal 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();
|
||||
Reference in New Issue
Block a user