Files
chatgpt-on-wechat/desktop/src/main/updater.ts

246 lines
10 KiB
TypeScript

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 ?? '<none>'}`)
// 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 ?? '<unknown>'} -> 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=TRUE on Windows. Our installer is now ASSISTED (nsis.oneClick=false
// + allowToChangeInstallationDirectory) so the FIRST install shows the
// directory/mode wizard. But an UPDATE must NOT re-show that wizard — isSilent
// skips it and updates in place. isForceRunAfter=true relaunches after the
// silent update. (The old assisted+silent force-run bug, #2179, was fixed
// upstream in PR #2278; we're on electron-updater 6.8.9, well past it.)
// setImmediate + removeAllListeners are the documented prerequisites for the
// relaunch to fire reliably. macOS ignores isSilent entirely.
setImmediate(() => autoUpdater.quitAndInstall(true, true))
}