mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
248 lines
10 KiB
TypeScript
248 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=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))
|
|
}
|