import { app, BrowserWindow } from 'electron' import fs from 'fs' import path from 'path' // electron-updater is CommonJS: its members live on module.exports, with no // meaningful default export. Under module=commonjs + esModuleInterop, a named // import compiles to `electron_updater_1.autoUpdater` and resolves correctly, // whereas `import pkg from 'electron-updater'` yields undefined. import { autoUpdater } from 'electron-updater' // Status payloads pushed to the renderer over the 'update-status' channel. // The renderer drives the NavRail badge + update panel from these. export type UpdateStatus = | { state: 'checking' } | { state: 'available'; version: string; notes?: string } | { state: 'not-available' } | { state: 'downloading'; percent: number } | { state: 'downloaded'; version: string } | { state: 'error'; message: string } let getWindow: () => BrowserWindow | null = () => null // The update feed. Both entries hit the same Pages Function // (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer // downloads to the China CDN mirror instead of R2. The feed metadata is // identical either way, so we can freely switch the feed URL between attempts // to fall back from one download origin to the other. const FEED_BASE = 'https://cowagent.ai/update/' const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE) // Which origin the current session prefers, derived from the app UI language // (zh -> China mirror). Downloads that fail on the preferred origin retry once // on the other one before surfacing an error. let preferChina = false // Guard so a single download only ever falls back once (avoids ping-pong). let downloadFellBack = false function applyFeedUrl(): void { const url = feedUrlFor(preferChina) try { autoUpdater.setFeedURL({ provider: 'generic', url }) log(`feed url set: ${url} (preferChina=${preferChina})`) } catch (err) { log(`feed url set failed: ${(err as Error)?.message || String(err)}`) } } // Called from the check/download IPC with the renderer's current UI language. export function setUpdateLanguage(lang: string | undefined): void { const china = (lang || '').toLowerCase().startsWith('zh') if (china !== preferChina) { preferChina = china if (app.isPackaged) applyFeedUrl() } } // Persist update logs to a file so a user hitting a silent "spinner never // resolves" can just send us userData/logs/updater.log. We can't rely on a // logging dep, so this is a tiny append-only writer, plus console for the // in-app log view / terminal. let logFile: string | null = null function initLogFile() { try { const dir = path.join(app.getPath('userData'), 'logs') fs.mkdirSync(dir, { recursive: true }) logFile = path.join(dir, 'updater.log') } catch { logFile = null } } function log(...parts: unknown[]) { const line = `[${new Date().toISOString()}] [updater] ${parts .map((p) => (typeof p === 'string' ? p : safeStringify(p))) .join(' ')}` // Console: shows up in the terminal (dev) and the packaged app's stdout. console.log(line) if (logFile) { try { fs.appendFileSync(logFile, line + '\n') } catch { // ignore disk errors — logging must never break the updater } } } function safeStringify(v: unknown): string { try { return JSON.stringify(v) } catch { return String(v) } } function send(status: UpdateStatus) { getWindow()?.webContents.send('update-status', status) } export function initUpdater(windowGetter: () => BrowserWindow | null): void { getWindow = windowGetter initLogFile() log(`init: appVersion=${app.getVersion()} packaged=${app.isPackaged} logFile=${logFile ?? ''}`) // In dev (not packaged) there's no update feed; skip wiring entirely so // electron-updater doesn't throw on the missing app-update.yml. if (!app.isPackaged) { log('not packaged — updater wiring skipped') return } // User-driven flow: we surface "available" and let the user opt in to the // download, rather than pulling bytes silently in the background. autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true // The desktop channel ships pre-release-tagged builds (e.g. 0.0.8-test), so a // current version like 0.0.7-test must be allowed to compare against, and be // offered, other pre-release versions. Without this electron-updater's semver // compare can silently skip pre-releases and neither "available" nor // "not-available" fires — the UI just spins forever. autoUpdater.allowPrerelease = true autoUpdater.allowDowngrade = false // Point at the preferred origin up front (defaults to R2; switched to the CN // mirror once the renderer reports a zh UI language via setUpdateLanguage). applyFeedUrl() // Route electron-updater's own internal logging to our file too, so we // capture the feed URL, parsed versions and any stack traces it logs. autoUpdater.logger = { info: (m: unknown) => log('eu-info:', m), warn: (m: unknown) => log('eu-warn:', m), error: (m: unknown) => log('eu-error:', m), debug: (m: unknown) => log('eu-debug:', m), } as unknown as typeof autoUpdater.logger autoUpdater.on('checking-for-update', () => { log(`checking-for-update: current=${app.getVersion()}`) send({ state: 'checking' }) }) autoUpdater.on('update-available', (info) => { log(`update-available: current=${app.getVersion()} remote=${info.version} -> update needed`) send({ state: 'available', version: info.version, notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, }) }) autoUpdater.on('update-not-available', (info) => { log(`update-not-available: current=${app.getVersion()} remote=${info?.version ?? ''} -> up to date`) send({ state: 'not-available' }) }) autoUpdater.on('download-progress', (p) => { log(`download-progress: ${Math.round(p.percent)}% (${p.transferred}/${p.total} bytes, ${Math.round(p.bytesPerSecond / 1024)} KB/s)`) send({ state: 'downloading', percent: Math.round(p.percent) }) }) autoUpdater.on('update-downloaded', (info) => { log(`update-downloaded: version=${info.version} -> ready to install`) send({ state: 'downloaded', version: info.version }) }) autoUpdater.on('error', (err) => { const message = err == null ? 'unknown' : err.message || String(err) log(`error: ${message}`, err instanceof Error && err.stack ? err.stack : '') send({ state: 'error', message }) }) } // Silent check shortly after launch. When not packaged there's no update feed, // but a manual click should still get visible feedback instead of looking dead: // reply "not-available" so the menu can show "up to date". export function checkForUpdates(): void { if (!app.isPackaged) { // Dev-only UI harness: set COW_MOCK_UPDATE=1 to simulate an available // update so the update panel/menu interactions can be exercised in // `npm run dev` (where there's no real feed). Never runs in a packaged app. if (process.env.COW_MOCK_UPDATE) { const version = process.env.COW_MOCK_UPDATE_VERSION || '9.9.9' log(`checkForUpdates: not packaged, MOCK available version=${version}`) send({ state: 'available', version }) return } log('checkForUpdates: not packaged, replying not-available') send({ state: 'not-available' }) return } log(`checkForUpdates: requesting feed, current=${app.getVersion()}`) autoUpdater.checkForUpdates().catch((err) => { const message = err?.message || String(err) log(`checkForUpdates: request failed: ${message}`, err instanceof Error && err.stack ? err.stack : '') send({ state: 'error', message }) }) } export function startDownload(): void { if (!app.isPackaged) return downloadFellBack = false log(`startDownload: user requested download (preferChina=${preferChina})`) attemptDownload() } // Download from the current origin; on failure, switch to the OTHER origin once // and retry. This is the client-side "mirrors back each other" fallback: R2 and // the China CDN hold identical bytes, so a slow/blocked origin can be swapped // transparently without the user noticing. function attemptDownload(): void { autoUpdater.downloadUpdate().catch((err) => { const message = err?.message || String(err) log(`startDownload: failed on ${preferChina ? 'CN' : 'R2'}: ${message}`, err instanceof Error && err.stack ? err.stack : '') if (!downloadFellBack) { downloadFellBack = true preferChina = !preferChina applyFeedUrl() log(`startDownload: retrying on ${preferChina ? 'CN' : 'R2'} mirror`) // Re-check first so electron-updater re-reads the feed from the new origin // before downloading (its cached updateInfo is origin-agnostic here, but a // fresh check keeps the internal state consistent). autoUpdater .checkForUpdates() .then(() => autoUpdater.downloadUpdate()) .catch((err2) => { const m2 = err2?.message || String(err2) log(`startDownload: fallback also failed: ${m2}`, err2 instanceof Error && err2.stack ? err2.stack : '') send({ state: 'error', message: m2 }) }) return } send({ state: 'error', message }) }) } export function quitAndInstall(): void { if (!app.isPackaged) return log('quitAndInstall: relaunching to install update') // Drop window-all-closed handlers first: a lingering handler can keep the // process alive and stop the installer from replacing files / relaunching // (a documented electron-updater gotcha, esp. on Windows NSIS). app.removeAllListeners('window-all-closed') // isSilent=FALSE on Windows. Our installer is oneClick (package.json // nsis.oneClick=true), so a non-silent install is still wizard-less/quick — // but crucially, with isSilent=true the isForceRunAfter flag is unreliable and // the app often installs yet never relaunches (electron-builder #1746: exactly // the "update succeeds but app doesn't reopen" symptom we hit on 0.9.7->0.9.8). // With isSilent=false, isForceRunAfter is ignored and relaunch is instead // governed by autoRunAppAfterInstall (default true), which reliably reopens the // app after a oneClick install. macOS ignores isSilent entirely. // setImmediate lets the current IPC callstack unwind and the app fully settle // into quit before the installer takes over — without it relaunch is flaky. setImmediate(() => autoUpdater.quitAndInstall(false, true)) }