mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): mac zip auto-update
This commit is contained in:
116
.github/scripts/register-releases.mjs
vendored
Normal file
116
.github/scripts/register-releases.mjs
vendored
Normal file
@@ -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:
|
||||||
|
// - <name>-<arch>.dmg -> manual download (filename / size / sha512)
|
||||||
|
// - <name>-<arch>.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}`)
|
||||||
57
.github/workflows/publish-desktop.yml
vendored
57
.github/workflows/publish-desktop.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
|||||||
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
|
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
|
||||||
# Read them back so we know exactly which objects to pull from R2.
|
# Read them back so we know exactly which objects to pull from R2.
|
||||||
out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \
|
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"
|
||||||
echo "$out" | node -e '
|
echo "$out" | node -e '
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -84,12 +84,14 @@ jobs:
|
|||||||
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
# filename is "v<VER>/<base>"; the R2 key is "desktop/<filename>".
|
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
|
||||||
for filename in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => console.log(r.filename))'); do
|
# auto-update file (update_filename: zip) for every row. Keys are
|
||||||
base="$(basename "$filename")"
|
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
|
||||||
key="desktop/${filename}"
|
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
|
||||||
echo "==> Downloading r2://${R2_BUCKET}/${key} -> dist/${base}"
|
base="$(basename "$key")"
|
||||||
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${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
|
--file "dist/${base}" --remote
|
||||||
done
|
done
|
||||||
echo "Downloaded:"; ls -la dist
|
echo "Downloaded:"; ls -la dist
|
||||||
@@ -110,9 +112,6 @@ jobs:
|
|||||||
VER: ${{ github.event.inputs.version }}
|
VER: ${{ github.event.inputs.version }}
|
||||||
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
|
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
|
||||||
run: |
|
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
|
# 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.
|
# become latest, so the site keeps serving the last stable build.
|
||||||
case "$VER" in
|
case "$VER" in
|
||||||
@@ -120,32 +119,17 @@ jobs:
|
|||||||
*) is_pre=0 ;;
|
*) is_pre=0 ;;
|
||||||
esac
|
esac
|
||||||
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
|
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
|
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
|
fi
|
||||||
|
|
||||||
for f in dist/*; do
|
# Re-hash the real (stapled) bytes and re-store every row with both the
|
||||||
base="$(basename "$f")"
|
# dmg (manual) and mac zip (auto-update) columns. Same script as the
|
||||||
size="$(stat -c%s "$f")"
|
# build stage; --latest also clears the previous latest per platform.
|
||||||
case "$base" in
|
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
|
||||||
*arm64.dmg) platform="mac-arm64" ;;
|
echo "==> D1 statements:"; cat d1.sql
|
||||||
*x64.dmg) platform="mac-x64" ;;
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||||
*.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"
|
|
||||||
|
|
||||||
- name: Create/update GitHub Release and attach installers
|
- name: Create/update GitHub Release and attach installers
|
||||||
if: github.event.inputs.github_release == 'true'
|
if: github.event.inputs.github_release == 'true'
|
||||||
@@ -162,6 +146,9 @@ jobs:
|
|||||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||||
--title "$tag" --generate-notes $prerelease
|
--title "$tag" --generate-notes $prerelease
|
||||||
fi
|
fi
|
||||||
# --clobber so re-runs overwrite the stapled/updated assets.
|
# --clobber so re-runs overwrite the stapled/updated assets. The mac
|
||||||
gh release upload "$tag" dist/*.dmg dist/*.exe \
|
# 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
|
--repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|||||||
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
@@ -246,7 +246,9 @@ jobs:
|
|||||||
# differential downloads) from every per-platform artifact dir. The
|
# differential downloads) from every per-platform artifact dir. The
|
||||||
# .yml feed is generated dynamically by the /update Function from D1,
|
# .yml feed is generated dynamically by the /update Function from D1,
|
||||||
# so the yml files themselves don't need to go to R2.
|
# 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
|
echo "Staged files:"; ls -la dist
|
||||||
# When the whole matrix failed there's nothing to publish; flag it so
|
# 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.
|
# the R2/D1 steps skip instead of writing an empty/partial release.
|
||||||
@@ -281,10 +283,6 @@ jobs:
|
|||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
VER: ${{ steps.ver.outputs.version }}
|
VER: ${{ steps.ver.outputs.version }}
|
||||||
run: |
|
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
|
# This build job ALWAYS registers rows as unpublished (is_latest=0), so
|
||||||
# /download/<p>/latest keeps serving the previous release and the new
|
# /download/<p>/latest keeps serving the previous release and the new
|
||||||
# version stays invisible on the site. macOS dmgs still need to be
|
# version stays invisible on the site. macOS dmgs still need to be
|
||||||
@@ -293,29 +291,10 @@ jobs:
|
|||||||
# notarization, via the separate "Publish Desktop" workflow.
|
# notarization, via the separate "Publish Desktop" workflow.
|
||||||
echo "==> Registering $VER as unpublished (is_latest=0)."
|
echo "==> Registering $VER as unpublished (is_latest=0)."
|
||||||
|
|
||||||
# Compute sha512 (base64, the format electron-updater validates) from
|
# Build one upsert per (version, platform) carrying both the dmg
|
||||||
# the actual staged files rather than electron-builder's latest*.yml.
|
# (manual download) and the mac zip (auto-update) columns. See
|
||||||
# NOTE: for macOS the dmg is re-hashed later by the publish workflow
|
# .github/scripts/register-releases.mjs for the mapping. No --latest
|
||||||
# after stapling (stapling rewrites the dmg bytes), so the sha stored
|
# here: rows stay unpublished until the publish workflow promotes them.
|
||||||
# here is a placeholder for mac and authoritative only for win.
|
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
|
||||||
sha_for_file() { openssl dgst -sha512 -binary "$1" | openssl base64 -A; }
|
echo "==> D1 statements:"; cat d1.sql
|
||||||
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||||
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"
|
|
||||||
|
|||||||
@@ -80,6 +80,13 @@
|
|||||||
"arm64",
|
"arm64",
|
||||||
"x64"
|
"x64"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "zip",
|
||||||
|
"arch": [
|
||||||
|
"arm64",
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import http from 'http'
|
|||||||
import { PythonBackend } from './python-manager'
|
import { PythonBackend } from './python-manager'
|
||||||
import { buildAppMenu } from './menu'
|
import { buildAppMenu } from './menu'
|
||||||
import { createTray, destroyTray } from './tray'
|
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,
|
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
|
||||||
// where the default Electron binary would otherwise report "Electron".
|
// where the default Electron binary would otherwise report "Electron".
|
||||||
@@ -242,9 +242,17 @@ function setupIPC() {
|
|||||||
// Current app version, shown in the NavRail footer.
|
// Current app version, shown in the NavRail footer.
|
||||||
ipcMain.handle('get-app-version', () => app.getVersion())
|
ipcMain.handle('get-app-version', () => app.getVersion())
|
||||||
|
|
||||||
// Auto-update controls (renderer-driven: check, then opt-in download/install)
|
// Auto-update controls (renderer-driven: check, then opt-in download/install).
|
||||||
ipcMain.handle('update-check', () => checkForUpdates())
|
// The renderer passes its current UI language so downloads can be routed to
|
||||||
ipcMain.handle('update-download', () => startDownload())
|
// 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())
|
ipcMain.handle('update-install', () => quitAndInstall())
|
||||||
|
|
||||||
// Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer
|
// Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// Current app version (e.g. "0.0.5"), shown in the NavRail footer.
|
// Current app version (e.g. "0.0.5"), shown in the NavRail footer.
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
|
|
||||||
// Auto-update: trigger checks/download/install and subscribe to status.
|
// Auto-update: trigger checks/download/install and subscribe to status. The
|
||||||
checkForUpdate: () => ipcRenderer.invoke('update-check'),
|
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
|
||||||
downloadUpdate: () => ipcRenderer.invoke('update-download'),
|
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),
|
||||||
|
downloadUpdate: (lang?: string) => ipcRenderer.invoke('update-download', lang),
|
||||||
installUpdate: () => ipcRenderer.invoke('update-install'),
|
installUpdate: () => ipcRenderer.invoke('update-install'),
|
||||||
onUpdateStatus: (callback: (status: unknown) => void) => {
|
onUpdateStatus: (callback: (status: unknown) => void) => {
|
||||||
const handler = (_event: unknown, status: unknown) => callback(status)
|
const handler = (_event: unknown, status: unknown) => callback(status)
|
||||||
|
|||||||
@@ -19,6 +19,40 @@ export type UpdateStatus =
|
|||||||
|
|
||||||
let getWindow: () => BrowserWindow | null = () => null
|
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
|
// 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
|
// 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
|
// 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.
|
// "not-available" fires — the UI just spins forever.
|
||||||
autoUpdater.allowPrerelease = true
|
autoUpdater.allowPrerelease = true
|
||||||
autoUpdater.allowDowngrade = false
|
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
|
// 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.
|
// capture the feed URL, parsed versions and any stack traces it logs.
|
||||||
autoUpdater.logger = {
|
autoUpdater.logger = {
|
||||||
@@ -154,10 +191,37 @@ export function checkForUpdates(): void {
|
|||||||
|
|
||||||
export function startDownload(): void {
|
export function startDownload(): void {
|
||||||
if (!app.isPackaged) return
|
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) => {
|
autoUpdater.downloadUpdate().catch((err) => {
|
||||||
const message = err?.message || String(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 })
|
send({ state: 'error', message })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
menu_website: '官网',
|
menu_website: '官网',
|
||||||
menu_docs: '文档中心',
|
menu_docs: '文档中心',
|
||||||
menu_skill_hub: '技能广场',
|
menu_skill_hub: '技能广场',
|
||||||
|
menu_feedback: '反馈',
|
||||||
// onboarding
|
// onboarding
|
||||||
onboarding_welcome_title: '欢迎使用 CowAgent',
|
onboarding_welcome_title: '欢迎使用 CowAgent',
|
||||||
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
|
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
|
||||||
@@ -453,6 +454,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
menu_website: 'Website',
|
menu_website: 'Website',
|
||||||
menu_docs: 'Documentation',
|
menu_docs: 'Documentation',
|
||||||
menu_skill_hub: 'Skill Hub',
|
menu_skill_hub: 'Skill Hub',
|
||||||
|
menu_feedback: 'Feedback',
|
||||||
// onboarding
|
// onboarding
|
||||||
onboarding_welcome_title: 'Welcome to CowAgent',
|
onboarding_welcome_title: 'Welcome to CowAgent',
|
||||||
onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
|
onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
FileText,
|
FileText,
|
||||||
Store,
|
Store,
|
||||||
|
MessageSquareWarning,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
// The desktop app's own brand icon (transparent PNG), bundled by Vite.
|
// 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
|
// English is the default (no suffix); Chinese gets a /zh suffix. Skill hub is
|
||||||
// language-agnostic.
|
// language-agnostic.
|
||||||
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
|
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 websiteUrl = () => (getLang() === 'zh' ? 'https://cowagent.ai/zh' : 'https://cowagent.ai')
|
||||||
const docsUrl = () => (getLang() === 'zh' ? 'https://docs.cowagent.ai/zh' : 'https://docs.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={<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={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
|
||||||
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
|
<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" />
|
<div className="my-1 border-t border-subtle" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { UpdateStatus } from '../types'
|
import type { UpdateStatus } from '../types'
|
||||||
|
import { getLang } from '../i18n'
|
||||||
|
|
||||||
interface UpdateState {
|
interface UpdateState {
|
||||||
status: UpdateStatus | null
|
status: UpdateStatus | null
|
||||||
@@ -57,10 +58,11 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
|||||||
set({ dismissedVersion: null, panelOpen: true })
|
set({ dismissedVersion: null, panelOpen: true })
|
||||||
}
|
}
|
||||||
// Always kick a fresh check too (picks up newer versions / recovers errors).
|
// 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?.(),
|
install: () => window.electronAPI?.installUpdate?.(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ export interface ElectronAPI {
|
|||||||
onMenuAction?: (callback: (action: string) => void) => () => void
|
onMenuAction?: (callback: (action: string) => void) => () => void
|
||||||
// Current app version string (e.g. "0.0.5").
|
// Current app version string (e.g. "0.0.5").
|
||||||
getAppVersion?: () => Promise<string>
|
getAppVersion?: () => Promise<string>
|
||||||
// Auto-update
|
// Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN.
|
||||||
checkForUpdate?: () => Promise<void>
|
checkForUpdate?: (lang?: string) => Promise<void>
|
||||||
downloadUpdate?: () => Promise<void>
|
downloadUpdate?: (lang?: string) => Promise<void>
|
||||||
installUpdate?: () => Promise<void>
|
installUpdate?: () => Promise<void>
|
||||||
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
|
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
|
||||||
platform: string
|
platform: string
|
||||||
|
|||||||
Reference in New Issue
Block a user