feat(desktop): mac zip auto-update

This commit is contained in:
zhayujie
2026-07-07 15:11:55 +08:00
parent 8df38a23d2
commit d531e14fbf
11 changed files with 254 additions and 80 deletions

View File

@@ -80,6 +80,13 @@
"arm64",
"x64"
]
},
{
"target": "zip",
"arch": [
"arm64",
"x64"
]
}
]
},

View File

@@ -5,7 +5,7 @@ import http from 'http'
import { PythonBackend } from './python-manager'
import { buildAppMenu } from './menu'
import { createTray, destroyTray } from './tray'
import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater'
import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater'
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
// where the default Electron binary would otherwise report "Electron".
@@ -242,9 +242,17 @@ function setupIPC() {
// Current app version, shown in the NavRail footer.
ipcMain.handle('get-app-version', () => app.getVersion())
// Auto-update controls (renderer-driven: check, then opt-in download/install)
ipcMain.handle('update-check', () => checkForUpdates())
ipcMain.handle('update-download', () => startDownload())
// Auto-update controls (renderer-driven: check, then opt-in download/install).
// The renderer passes its current UI language so downloads can be routed to
// the China CDN mirror (zh) or R2 (others).
ipcMain.handle('update-check', (_event, lang?: string) => {
setUpdateLanguage(lang)
checkForUpdates()
})
ipcMain.handle('update-download', (_event, lang?: string) => {
setUpdateLanguage(lang)
startDownload()
})
ipcMain.handle('update-install', () => quitAndInstall())
// Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer

View File

@@ -43,9 +43,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Current app version (e.g. "0.0.5"), shown in the NavRail footer.
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
// Auto-update: trigger checks/download/install and subscribe to status.
checkForUpdate: () => ipcRenderer.invoke('update-check'),
downloadUpdate: () => ipcRenderer.invoke('update-download'),
// Auto-update: trigger checks/download/install and subscribe to status. The
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),
downloadUpdate: (lang?: string) => ipcRenderer.invoke('update-download', lang),
installUpdate: () => ipcRenderer.invoke('update-install'),
onUpdateStatus: (callback: (status: unknown) => void) => {
const handler = (_event: unknown, status: unknown) => callback(status)

View File

@@ -19,6 +19,40 @@ export type UpdateStatus =
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
@@ -86,6 +120,9 @@ export function initUpdater(windowGetter: () => BrowserWindow | null): void {
// "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 = {
@@ -154,10 +191,37 @@ export function checkForUpdates(): void {
export function startDownload(): void {
if (!app.isPackaged) return
log('startDownload: user requested download')
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: ${message}`, err instanceof Error && err.stack ? err.stack : '')
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 })
})
}

View File

@@ -84,6 +84,7 @@ const translations: Record<string, Record<string, string>> = {
menu_website: '官网',
menu_docs: '文档中心',
menu_skill_hub: '技能广场',
menu_feedback: '反馈',
// onboarding
onboarding_welcome_title: '欢迎使用 CowAgent',
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
@@ -453,6 +454,7 @@ const translations: Record<string, Record<string, string>> = {
menu_website: 'Website',
menu_docs: 'Documentation',
menu_skill_hub: 'Skill Hub',
menu_feedback: 'Feedback',
// onboarding
onboarding_welcome_title: 'Welcome to CowAgent',
onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',

View File

@@ -20,6 +20,7 @@ import {
Globe,
FileText,
Store,
MessageSquareWarning,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
// The desktop app's own brand icon (transparent PNG), bundled by Vite.
@@ -41,6 +42,8 @@ const FALLBACK_VERSION = '2.1.3'
// English is the default (no suffix); Chinese gets a /zh suffix. Skill hub is
// language-agnostic.
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
// GitHub issues — where users report bugs / request features.
const FEEDBACK_URL = 'https://github.com/zhayujie/CowAgent/issues'
const websiteUrl = () => (getLang() === 'zh' ? 'https://cowagent.ai/zh' : 'https://cowagent.ai')
const docsUrl = () => (getLang() === 'zh' ? 'https://docs.cowagent.ai/zh' : 'https://docs.cowagent.ai')
@@ -324,6 +327,11 @@ const FooterMenu: React.FC<{
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
<MenuItem
icon={<MessageSquareWarning size={16} />}
label={t('menu_feedback')}
onClick={() => onOpenLink(FEEDBACK_URL)}
/>
<div className="my-1 border-t border-subtle" />

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { UpdateStatus } from '../types'
import { getLang } from '../i18n'
interface UpdateState {
status: UpdateStatus | null
@@ -57,10 +58,11 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
set({ dismissedVersion: null, panelOpen: true })
}
// Always kick a fresh check too (picks up newer versions / recovers errors).
window.electronAPI?.checkForUpdate?.()
// Pass the UI language so downloads route to the China CDN / R2 accordingly.
window.electronAPI?.checkForUpdate?.(getLang())
},
download: () => window.electronAPI?.downloadUpdate?.(),
download: () => window.electronAPI?.downloadUpdate?.(getLang()),
install: () => window.electronAPI?.installUpdate?.(),
}))

View File

@@ -21,9 +21,9 @@ export interface ElectronAPI {
onMenuAction?: (callback: (action: string) => void) => () => void
// Current app version string (e.g. "0.0.5").
getAppVersion?: () => Promise<string>
// Auto-update
checkForUpdate?: () => Promise<void>
downloadUpdate?: () => Promise<void>
// Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN.
checkForUpdate?: (lang?: string) => Promise<void>
downloadUpdate?: (lang?: string) => Promise<void>
installUpdate?: () => Promise<void>
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
platform: string