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 log from './logger'; import { safeJsonParse, errorMessage } from './conversionUtils'; 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 = process.env.GITHUB_OWNER || 'block'; private readonly repo = process.env.GITHUB_REPO || 'goose'; private readonly bundleName = process.env.GOOSE_BUNDLE_NAME || 'Goose'; private readonly apiUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/latest`; async checkForUpdates(): Promise { const startTime = Date.now(); try { log.info('=== GitHubUpdater: STARTING UPDATE CHECK ==='); log.info(`GitHubUpdater: API URL: ${this.apiUrl}`); log.info(`GitHubUpdater: Current app version: ${app.getVersion()}`); log.info(`GitHubUpdater: Timestamp: ${new Date().toISOString()}`); log.info('GitHubUpdater: Initiating fetch request...'); const controller = new AbortController(); const timeoutId = setTimeout(() => { log.error('GitHubUpdater: Fetch request timed out after 30 seconds'); controller.abort(); }, 30000); const response = await fetch(this.apiUrl, { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': `Goose-Desktop/${app.getVersion()}`, }, signal: controller.signal, }); clearTimeout(timeoutId); const fetchDuration = Date.now() - startTime; log.info( `GitHubUpdater: GitHub API response status: ${response.status} ${response.statusText} (took ${fetchDuration}ms)` ); if (!response.ok) { const errorText = await response.text(); log.error(`GitHubUpdater: GitHub API error response: ${errorText}`); throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`); } const release: GitHubRelease = await safeJsonParse( response, 'Failed to get GitHub release information' ); log.info(`GitHubUpdater: Found release: ${release.tag_name} (${release.name})`); log.info(`GitHubUpdater: Release published at: ${release.published_at}`); log.info(`GitHubUpdater: Release assets count: ${release.assets.length}`); 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; log.info(`GitHubUpdater: Update available: ${updateAvailable}`); 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; log.info(`GitHubUpdater: Looking for asset for platform: ${platform}, arch: ${arch}`); if (platform === 'darwin') { // macOS if (arch === 'arm64') { assetName = `${this.bundleName}.zip`; } else { assetName = `${this.bundleName}_intel_mac.zip`; } } else if (platform === 'win32') { // Windows - for future support assetName = `${this.bundleName}-win32-x64.zip`; } else { // Linux - for future support assetName = `${this.bundleName}-linux-${arch}.zip`; } log.info(`GitHubUpdater: Looking for asset named: ${assetName}`); log.info(`GitHubUpdater: Available assets: ${release.assets.map((a) => a.name).join(', ')}`); const asset = release.assets.find((a) => a.name.toLowerCase() === assetName.toLowerCase()); // keeping comparison to lowercase because Goose vs goose if (asset) { downloadUrl = asset.browser_download_url; log.info(`GitHubUpdater: Found matching asset: ${asset.name} (${asset.size} bytes)`); log.info(`GitHubUpdater: Download URL: ${downloadUrl}`); } else { log.warn(`GitHubUpdater: No matching asset found for ${assetName}`); } if (!downloadUrl) { throw new Error( `Update Available but no download URL found for platform: ${platform}, arch: ${arch}` ); } return { updateAvailable: true, latestVersion, downloadUrl, releaseUrl: release.html_url, }; } catch (error) { log.error('GitHubUpdater: Error checking for updates:', error); log.error('GitHubUpdater: Error details:', { message: errorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : 'No stack', name: error instanceof Error ? error.name : 'Unknown', code: error instanceof Error && 'code' in error ? (error as Error & { code: unknown }).code : undefined, }); return { updateAvailable: false, error: errorMessage(error, 'Unknown error'), }; } } async downloadUpdate( downloadUrl: string, latestVersion: string, onProgress?: (percent: number) => void ): Promise<{ success: boolean; downloadPath?: string; extractedPath?: string; error?: string }> { const downloadStartTime = Date.now(); try { log.info('=== GitHubUpdater: STARTING DOWNLOAD ==='); log.info(`GitHubUpdater: Download URL: ${downloadUrl}`); log.info(`GitHubUpdater: Version: ${latestVersion}`); log.info(`GitHubUpdater: Timestamp: ${new Date().toISOString()}`); log.info('GitHubUpdater: Initiating download fetch request...'); const response = await fetch(downloadUrl); const fetchDuration = Date.now() - downloadStartTime; log.info( `GitHubUpdater: Download response received in ${fetchDuration}ms - Status: ${response.status} ${response.statusText}` ); 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; log.info( `GitHubUpdater: Content-Length: ${totalSize} bytes (${(totalSize / 1024 / 1024).toFixed(2)} MB)` ); if (!response.body) { throw new Error('Response body is null'); } let lastReportedPercent = -1; // Track last reported percentage to throttle updates let lastLoggedPercent = -1; // Track for logging at 10% intervals // Read the response stream log.info('GitHubUpdater: Starting to read response stream...'); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let downloadedSize = 0; let lastProgressTime = Date.now(); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); downloadedSize += value.length; // Report progress - only when percentage changes by at least 1% if (totalSize > 0 && onProgress) { const percent = Math.round((downloadedSize / totalSize) * 100); // Only report if percent changed (throttles from hundreds/sec to ~100 total) if (percent !== lastReportedPercent) { onProgress(percent); lastReportedPercent = percent; // Log at 10% intervals for debugging if (percent % 10 === 0 && percent !== lastLoggedPercent) { const elapsed = Date.now() - downloadStartTime; const speed = downloadedSize / (elapsed / 1000) / 1024; // KB/s log.info( `GitHubUpdater: Download progress ${percent}% (${(downloadedSize / 1024 / 1024).toFixed(2)}/${(totalSize / 1024 / 1024).toFixed(2)} MB) @ ${speed.toFixed(0)} KB/s` ); lastLoggedPercent = percent; } } } // Warn if no progress for 30 seconds const now = Date.now(); if (now - lastProgressTime > 30000) { log.warn( `GitHubUpdater: Download appears slow - no significant progress in 30 seconds (${downloadedSize}/${totalSize} bytes)` ); lastProgressTime = now; } else if (value.length > 0) { lastProgressTime = now; } } const downloadDuration = Date.now() - downloadStartTime; const avgSpeed = downloadedSize / (downloadDuration / 1000) / 1024; log.info( `GitHubUpdater: Download stream complete - ${downloadedSize} bytes in ${downloadDuration}ms (avg ${avgSpeed.toFixed(0)} KB/s)` ); // Combine chunks into a single buffer log.info('GitHubUpdater: Combining chunks into buffer...'); // eslint-disable-next-line no-undef const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); log.info(`GitHubUpdater: Buffer created - ${buffer.length} bytes`); // Save to Downloads directory const downloadsDir = path.join(os.homedir(), 'Downloads'); const fileName = `${this.bundleName}-${latestVersion}.zip`; const downloadPath = path.join(downloadsDir, fileName); log.info(`GitHubUpdater: Writing file to ${downloadPath}...`); await fs.writeFile(downloadPath, buffer); const totalDuration = Date.now() - downloadStartTime; log.info(`=== GitHubUpdater: DOWNLOAD COMPLETE in ${totalDuration}ms ===`); log.info(`GitHubUpdater: File saved to ${downloadPath}`); // Return success - user will handle extraction manually return { success: true, downloadPath, extractedPath: downloadsDir }; } catch (error) { const duration = Date.now() - downloadStartTime; log.error(`=== GitHubUpdater: DOWNLOAD FAILED after ${duration}ms ===`); log.error('GitHubUpdater: Error downloading update:', error); log.error('GitHubUpdater: Download error details:', { message: errorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : 'No stack', name: error instanceof Error ? error.name : 'Unknown', }); return { success: false, error: errorMessage(error, 'Unknown error'), }; } } } // Create singleton instance export const githubUpdater = new GitHubUpdater();