From d531e14fbf282d5a5585e575ac52ff224ec993a6 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 7 Jul 2026 15:11:55 +0800 Subject: [PATCH] feat(desktop): mac zip auto-update --- .github/scripts/register-releases.mjs | 116 ++++++++++++++++++ .github/workflows/publish-desktop.yml | 57 ++++----- .github/workflows/release.yml | 41 ++----- desktop/package.json | 7 ++ desktop/src/main/index.ts | 16 ++- desktop/src/main/preload.ts | 7 +- desktop/src/main/updater.ts | 68 +++++++++- desktop/src/renderer/src/i18n.ts | 2 + desktop/src/renderer/src/layout/NavRail.tsx | 8 ++ desktop/src/renderer/src/store/updateStore.ts | 6 +- desktop/src/renderer/src/types.ts | 6 +- 11 files changed, 254 insertions(+), 80 deletions(-) create mode 100644 .github/scripts/register-releases.mjs diff --git a/.github/scripts/register-releases.mjs b/.github/scripts/register-releases.mjs new file mode 100644 index 00000000..5109ce96 --- /dev/null +++ b/.github/scripts/register-releases.mjs @@ -0,0 +1,116 @@ +// Build the D1 upsert SQL for a desktop release from the files in a directory. +// +// Each mac release has TWO artifacts that map to a SINGLE D1 row: +// - -.dmg -> manual download (filename / size / sha512) +// - -.zip -> auto-update (update_filename / update_size / +// update_sha512) +// electron-updater's MacUpdater can only consume a zip, never a dmg, so the +// feed serves the zip while the website serves the dmg. Windows has only the +// .exe (stored in the main columns; it's both the download and the update). +// +// We emit ONE `INSERT OR REPLACE` per (version, platform) carrying BOTH halves, +// because two replaces on the same primary key would drop whichever came first. +// +// Usage: +// node register-releases.mjs --dir dist --version 1.2.0 \ +// --sql out.sql [--latest] +// +// --latest mark these rows is_latest=1 AND clear the previous latest for +// each platform (used by the publish/promote workflow). Without it +// rows are written unpublished (is_latest=0) — the build stage. +// +// sha512 is base64 (the exact format electron-updater validates). + +import { execSync } from 'node:child_process' +import fs from 'node:fs' + +function arg(name, fallback = undefined) { + const i = process.argv.indexOf(`--${name}`) + if (i === -1) return fallback + const next = process.argv[i + 1] + // Boolean flag (no value or next token is another flag). + if (next === undefined || next.startsWith('--')) return true + return next +} + +const dir = arg('dir', 'dist') +const version = arg('version') +const sqlPath = arg('sql', 'd1.sql') +const makeLatest = arg('latest', false) === true + +if (!version) { + console.error('register-releases: --version is required') + process.exit(1) +} + +const sha512 = (f) => + execSync(`openssl dgst -sha512 -binary "${f}" | openssl base64 -A`, { + shell: '/bin/bash', + }) + .toString() + .trim() + +// SQL-escape single quotes (base64/keys shouldn't contain them, but be safe). +const q = (s) => String(s).replace(/'/g, "''") + +// platform -> { main: {key,size,sha}, upd: {key,size,sha} } +const rows = {} + +for (const base of fs.readdirSync(dir)) { + const f = `${dir}/${base}` + if (fs.statSync(f).isDirectory()) continue + + let platform + let slot + if (/arm64\.dmg$/.test(base)) { + platform = 'mac-arm64' + slot = 'main' + } else if (/x64\.dmg$/.test(base)) { + platform = 'mac-x64' + slot = 'main' + } else if (/arm64\.zip$/.test(base)) { + platform = 'mac-arm64' + slot = 'upd' + } else if (/x64\.zip$/.test(base)) { + platform = 'mac-x64' + slot = 'upd' + } else if (/\.exe$/.test(base)) { + platform = 'win' + slot = 'main' + } else { + console.log('Skipping unrecognized artifact:', base) + continue + } + + rows[platform] ||= {} + rows[platform][slot] = { + key: `v${version}/${base}`, + size: fs.statSync(f).size, + sha: sha512(f), + } +} + +if (Object.keys(rows).length === 0) { + console.error('register-releases: no recognized artifacts in', dir) + process.exit(1) +} + +const isLatest = makeLatest ? 1 : 0 +const sql = [] +for (const [platform, r] of Object.entries(rows)) { + const m = r.main || { key: '', size: 0, sha: '' } + const u = r.upd || { key: '', size: 0, sha: '' } + if (makeLatest) { + // Clear the previous latest for this platform before promoting the new row. + sql.push(`UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';`) + } + sql.push( + `INSERT OR REPLACE INTO releases ` + + `(version, platform, filename, size, sha512, update_filename, update_size, update_sha512, is_latest) ` + + `VALUES ('${version}', '${platform}', '${q(m.key)}', ${m.size}, '${q(m.sha)}', ` + + `'${q(u.key)}', ${u.size}, '${q(u.sha)}', ${isLatest});` + ) +} + +fs.writeFileSync(sqlPath, sql.join('\n') + '\n') +console.log(`register-releases: wrote ${sql.length} statement(s) to ${sqlPath}`) diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index 41658146..6f2270bb 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -62,7 +62,7 @@ jobs: # The build stage inserted one row per artifact with filename = v/. # Read them back so we know exactly which objects to pull from R2. out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \ - --command "SELECT platform, filename FROM releases WHERE version = '${VER}';")" + --command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")" echo "$out" echo "$out" | node -e ' const fs = require("fs"); @@ -84,12 +84,14 @@ jobs: R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }} run: | mkdir -p dist - # filename is "v/"; the R2 key is "desktop/". - for filename in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => console.log(r.filename))'); do - base="$(basename "$filename")" - key="desktop/${filename}" - echo "==> Downloading r2://${R2_BUCKET}/${key} -> dist/${base}" - npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${key}" \ + # Pull BOTH the manual-download file (filename: dmg/exe) and the mac + # auto-update file (update_filename: zip) for every row. Keys are + # "v/"; the R2 key is "desktop/". + for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do + base="$(basename "$key")" + r2key="desktop/${key}" + echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}" + npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \ --file "dist/${base}" --remote done echo "Downloaded:"; ls -la dist @@ -110,9 +112,6 @@ jobs: VER: ${{ github.event.inputs.version }} MAKE_LATEST: ${{ github.event.inputs.make_latest }} run: | - sql_file="$(mktemp)" - sha_for_file() { openssl dgst -sha512 -binary "$1" | openssl base64 -A; } - # Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never # become latest, so the site keeps serving the last stable build. case "$VER" in @@ -120,32 +119,17 @@ jobs: *) is_pre=0 ;; esac if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then - do_latest=1; echo "==> Publishing $VER as latest." + latest_flag="--latest"; echo "==> Publishing $VER as latest." else - do_latest=0; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)." + latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)." fi - for f in dist/*; do - base="$(basename "$f")" - size="$(stat -c%s "$f")" - case "$base" in - *arm64.dmg) platform="mac-arm64" ;; - *x64.dmg) platform="mac-x64" ;; - *.exe) platform="win" ;; - *) echo "Skipping unrecognized artifact: $base"; continue ;; - esac - key="v${VER}/${base}" - sha="$(sha_for_file "$f")" - sha="${sha//\'/\'\'}" - if [ "$do_latest" = "1" ]; then - echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file" - fi - # Re-store the row with the authoritative (post-staple) sha and the - # resolved latest flag. - echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, sha512, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, '${sha}', ${do_latest});" >> "$sql_file" - done - echo "==> D1 statements:"; cat "$sql_file" - npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file" + # Re-hash the real (stapled) bytes and re-store every row with both the + # dmg (manual) and mac zip (auto-update) columns. Same script as the + # build stage; --latest also clears the previous latest per platform. + node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag + echo "==> D1 statements:"; cat d1.sql + npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql - name: Create/update GitHub Release and attach installers if: github.event.inputs.github_release == 'true' @@ -162,6 +146,9 @@ jobs: gh release create "$tag" --repo "$GITHUB_REPOSITORY" \ --title "$tag" --generate-notes $prerelease fi - # --clobber so re-runs overwrite the stapled/updated assets. - gh release upload "$tag" dist/*.dmg dist/*.exe \ + # --clobber so re-runs overwrite the stapled/updated assets. The mac + # zip is the auto-update artifact; attach it too so the GitHub Release + # is a complete mirror (nullglob avoids errors when a type is absent). + shopt -s nullglob + gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \ --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d016f3ec..5500a3db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -246,7 +246,9 @@ jobs: # differential downloads) from every per-platform artifact dir. The # .yml feed is generated dynamically by the /update Function from D1, # so the yml files themselves don't need to go to R2. - find artifacts -type f \( -name '*.dmg' -o -name '*.exe' -o -name '*.blockmap' \) -exec cp {} dist/ \; + # .zip is the mac auto-update artifact (electron-updater's MacUpdater + # can ONLY consume zip, not dmg — the dmg is for manual downloads). + find artifacts -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) -exec cp {} dist/ \; echo "Staged files:"; ls -la dist # When the whole matrix failed there's nothing to publish; flag it so # the R2/D1 steps skip instead of writing an empty/partial release. @@ -281,10 +283,6 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} VER: ${{ steps.ver.outputs.version }} run: | - # Map each installer filename to a platform id. dmg arch is in the - # name (…-arm64.dmg / …-x64.dmg); .exe is the Windows installer. - sql_file="$(mktemp)" - # This build job ALWAYS registers rows as unpublished (is_latest=0), so # /download/

/latest keeps serving the previous release and the new # version stays invisible on the site. macOS dmgs still need to be @@ -293,29 +291,10 @@ jobs: # notarization, via the separate "Publish Desktop" workflow. echo "==> Registering $VER as unpublished (is_latest=0)." - # Compute sha512 (base64, the format electron-updater validates) from - # the actual staged files rather than electron-builder's latest*.yml. - # NOTE: for macOS the dmg is re-hashed later by the publish workflow - # after stapling (stapling rewrites the dmg bytes), so the sha stored - # here is a placeholder for mac and authoritative only for win. - sha_for_file() { openssl dgst -sha512 -binary "$1" | openssl base64 -A; } - - for f in dist/*; do - base="$(basename "$f")" - size="$(stat -c%s "$f")" - case "$base" in - *arm64.dmg) platform="mac-arm64" ;; - *x64.dmg) platform="mac-x64" ;; - *.exe) platform="win" ;; - *) echo "Skipping unrecognized artifact: $base"; continue ;; - esac - key="v${VER}/${base}" - sha="$(sha_for_file "$f")" - # SQL-escape single quotes defensively (base64 has none, but be safe). - sha="${sha//\'/\'\'}" - # Never mark latest here — the row is unpublished until the separate - # publish workflow promotes it after notarization. - echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, sha512, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, '${sha}', 0);" >> "$sql_file" - done - echo "==> D1 statements:"; cat "$sql_file" - npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file" + # Build one upsert per (version, platform) carrying both the dmg + # (manual download) and the mac zip (auto-update) columns. See + # .github/scripts/register-releases.mjs for the mapping. No --latest + # here: rows stay unpublished until the publish workflow promotes them. + node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql + echo "==> D1 statements:"; cat d1.sql + npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql diff --git a/desktop/package.json b/desktop/package.json index 5dc2c595..408d8e68 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -80,6 +80,13 @@ "arm64", "x64" ] + }, + { + "target": "zip", + "arch": [ + "arm64", + "x64" + ] } ] }, diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index b0b83a8b..d47052d4 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -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 diff --git a/desktop/src/main/preload.ts b/desktop/src/main/preload.ts index 93ce92e0..b0fcfacb 100644 --- a/desktop/src/main/preload.ts +++ b/desktop/src/main/preload.ts @@ -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) diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index db6f5e75..048e165a 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -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 }) }) } diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index c751959a..e0b45601 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -84,6 +84,7 @@ const translations: Record> = { 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> = { 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.', diff --git a/desktop/src/renderer/src/layout/NavRail.tsx b/desktop/src/renderer/src/layout/NavRail.tsx index 5b4dbeab..92aa90b0 100644 --- a/desktop/src/renderer/src/layout/NavRail.tsx +++ b/desktop/src/renderer/src/layout/NavRail.tsx @@ -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<{ } label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} /> } label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} /> } label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} /> + } + label={t('menu_feedback')} + onClick={() => onOpenLink(FEEDBACK_URL)} + />

diff --git a/desktop/src/renderer/src/store/updateStore.ts b/desktop/src/renderer/src/store/updateStore.ts index 9af2d396..a64a5eb5 100644 --- a/desktop/src/renderer/src/store/updateStore.ts +++ b/desktop/src/renderer/src/store/updateStore.ts @@ -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((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?.(), })) diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts index 3d1aedf7..63644385 100644 --- a/desktop/src/renderer/src/types.ts +++ b/desktop/src/renderer/src/types.ts @@ -21,9 +21,9 @@ export interface ElectronAPI { onMenuAction?: (callback: (action: string) => void) => () => void // Current app version string (e.g. "0.0.5"). getAppVersion?: () => Promise - // Auto-update - checkForUpdate?: () => Promise - downloadUpdate?: () => Promise + // Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN. + checkForUpdate?: (lang?: string) => Promise + downloadUpdate?: (lang?: string) => Promise installUpdate?: () => Promise onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void platform: string