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:
+10
-2
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 630 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
+28
-17
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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