From e9352e6984e1ed6f20023113d434ebecc09812eb Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 23 Jun 2026 20:37:27 +0800 Subject: [PATCH] feat(desktop): add auto-update via electron-updater + manual CI trigger --- .github/workflows/release.yml | 235 ++++++++++++++++++ desktop/package-lock.json | 102 +++++++- desktop/package.json | 9 +- desktop/src/main/index.ts | 11 + desktop/src/main/preload.ts | 10 + desktop/src/main/updater.ts | 72 ++++++ desktop/src/renderer/src/App.tsx | 4 + .../renderer/src/components/UpdateBanner.tsx | 97 ++++++++ desktop/src/renderer/src/i18n.ts | 12 + desktop/src/renderer/src/layout/NavRail.tsx | 6 + desktop/src/renderer/src/store/updateStore.ts | 55 ++++ desktop/src/renderer/src/types.ts | 14 ++ 12 files changed, 620 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 desktop/src/main/updater.ts create mode 100644 desktop/src/renderer/src/components/UpdateBanner.tsx create mode 100644 desktop/src/renderer/src/store/updateStore.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..db0b632e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,235 @@ +name: Release Desktop + +# Tag-driven release: push a tag like `v1.2.0` to build and publish the +# desktop client for macOS (arm64 + x64) and Windows (x64). The tag is the +# single source of truth for the version — it's written into package.json at +# build time, so the maintainer never edits the version by hand. +on: + push: + tags: + - "v*" + # Manual trigger for testing the full pipeline without cutting a real tag. + workflow_dispatch: + inputs: + version: + description: "Version to stamp (e.g. 1.0.0-test). Used for package.json and R2 path." + type: string + default: "0.0.0-dev" + publish_r2: + description: "Upload installers to R2 + register in D1 (needs Cloudflare secrets)" + type: boolean + default: false + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + # Don't cancel the other platforms if one fails — we want to see all + # failures in a single run. + fail-fast: false + matrix: + include: + - name: macOS arm64 + os: macos-14 + platform: mac + arch: arm64 + eb_flags: --mac --arm64 + - name: macOS x64 + os: macos-13 + platform: mac + arch: x64 + eb_flags: --mac --x64 + - name: Windows x64 + os: windows-latest + platform: win + arch: x64 + eb_flags: --win --x64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Derive version + # Tag push: strip the leading "v" from GITHUB_REF_NAME (e.g. v1.2.0). + # Manual dispatch: use the provided version input. + id: ver + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + ref="${GITHUB_REF_NAME:-}" + echo "version=${ref#v}" >> "$GITHUB_OUTPUT" + else + echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Build Python backend (PyInstaller) + shell: bash + run: | + python -m pip install --upgrade pip + pip install -r desktop/build/requirements-desktop.txt + pip install pyinstaller + # Run from repo root so the spec's relative datas resolve correctly. + pyinstaller desktop/build/cowagent-backend.spec \ + --noconfirm \ + --distpath desktop/build/dist \ + --workpath desktop/build/build-work + + - name: Install desktop deps + working-directory: desktop + run: npm ci + + - name: Write version into package.json + working-directory: desktop + shell: bash + run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version + + - name: Build & publish (electron-builder) + working-directory: desktop + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # macOS signing/notarization (no-ops when the secrets are unset, so + # unsigned builds still succeed until certificates are provisioned). + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Windows signing (also a no-op when unset). + WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} + WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + run: | + npm run build + # Publish to the GitHub Release on tag pushes; otherwise build only. + if [ "${{ github.event_name }}" = "push" ]; then + PUBLISH=always + else + PUBLISH=never + fi + npx electron-builder ${{ matrix.eb_flags }} --publish "$PUBLISH" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + # One bundle per platform/arch so the publish job can collect them all. + name: cowagent-${{ matrix.platform }}-${{ matrix.arch }} + path: | + desktop/release/*.dmg + desktop/release/*.exe + desktop/release/*.yml + desktop/release/*.blockmap + if-no-files-found: ignore + retention-days: 7 + + # Mirror the release installers to R2 (CDN-backed) and register them in D1 so + # cowagent.ai/download/{platform}/latest can resolve and count downloads. + # Runs only on tag pushes, and is a no-op (skips) until the Cloudflare secrets + # are configured, so it never blocks unsigned/dry builds. + publish-r2: + name: Publish to R2 + D1 + needs: build + runs-on: ubuntu-latest + # Run on a tag push, or on a manual dispatch when publish_r2 is checked. + if: >- + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || + (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_r2 == 'true') + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Guard on Cloudflare secrets + id: guard + env: + CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + if [ -n "$CF_TOKEN" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish." + fi + + - name: Derive version + if: steps.guard.outputs.enabled == 'true' + id: ver + run: | + if [ "${{ github.event_name }}" = "push" ]; then + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" + fi + + - name: Download all build artifacts + if: steps.guard.outputs.enabled == 'true' + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Stage installers + if: steps.guard.outputs.enabled == 'true' + id: stage + run: | + mkdir -p dist + # Flatten installers from every per-platform artifact dir; only the + # user-facing installers go to R2 (updater .yml/.blockmap stay on the + # GitHub Release, which electron-updater reads directly). + find artifacts -type f \( -name '*.dmg' -o -name '*.exe' \) -exec cp {} dist/ \; + echo "Staged files:"; ls -la dist + + - name: Upload installers to R2 + if: steps.guard.outputs.enabled == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + VER: ${{ steps.ver.outputs.version }} + run: | + # Reuse the existing cow-skills bucket under a desktop/ prefix; this + # is served by the cdn.cowagent.ai custom domain. + for f in dist/*; do + base="$(basename "$f")" + key="desktop/v${VER}/${base}" + echo "==> Uploading $base -> r2://cow-skills/$key" + npx --yes wrangler@latest r2 object put "cow-skills/$key" \ + --file "$f" --remote + done + + - name: Register release rows in D1 + if: steps.guard.outputs.enabled == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + 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)" + # Clear the latest flag first; we re-set it for this version below. + echo "UPDATE releases SET is_latest = 0;" >> "$sql_file" + 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}" + echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, 1);" >> "$sql_file" + done + echo "==> D1 statements:"; cat "$sql_file" + npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file" diff --git a/desktop/package-lock.json b/desktop/package-lock.json index f3eee362..24575efa 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "electron-updater": "^6.8.9", "highlight.js": "^11.11.1", "lucide-react": "^1.21.0", "markdown-it": "^14.2.0", @@ -3488,7 +3489,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4032,6 +4032,82 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4753,7 +4829,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -5235,7 +5310,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5316,7 +5390,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lazystream": { @@ -5431,6 +5504,12 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", @@ -5439,6 +5518,13 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -5884,7 +5970,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -7044,7 +7129,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -7609,6 +7693,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/desktop/package.json b/desktop/package.json index 472c00cd..cc2be698 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -18,6 +18,7 @@ "dist:win": "npm run build && electron-builder --win" }, "dependencies": { + "electron-updater": "^6.8.9", "highlight.js": "^11.11.1", "lucide-react": "^1.21.0", "markdown-it": "^14.2.0", @@ -63,7 +64,8 @@ { "target": "dmg", "arch": [ - "arm64" + "arm64", + "x64" ] } ] @@ -84,6 +86,11 @@ "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, "createStartMenuShortcut": true + }, + "publish": { + "provider": "github", + "owner": "zhayujie", + "repo": "chatgpt-on-wechat" } } } diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 50dbf679..acafb341 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -5,6 +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' let mainWindow: BrowserWindow | null = null let pythonBackend: PythonBackend | null = null @@ -208,6 +209,11 @@ function setupIPC() { }) ipcMain.handle('window-close', () => mainWindow?.close()) ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false) + + // Auto-update controls (renderer-driven: check, then opt-in download/install) + ipcMain.handle('update-check', () => checkForUpdates()) + ipcMain.handle('update-download', () => startDownload()) + ipcMain.handle('update-install', () => quitAndInstall()) } function emitMaximizeState() { @@ -259,6 +265,11 @@ app.whenReady().then(async () => { }) await startBackend() + // Wire auto-update and do a first silent check a few seconds after launch so + // it doesn't compete with backend startup for resources. + initUpdater(() => mainWindow) + setTimeout(() => checkForUpdates(), 5000) + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() diff --git a/desktop/src/main/preload.ts b/desktop/src/main/preload.ts index ecb36408..42cf6e11 100644 --- a/desktop/src/main/preload.ts +++ b/desktop/src/main/preload.ts @@ -39,5 +39,15 @@ contextBridge.exposeInMainWorld('electronAPI', { return () => ipcRenderer.removeListener('menu-action', handler) }, + // Auto-update: trigger checks/download/install and subscribe to status. + checkForUpdate: () => ipcRenderer.invoke('update-check'), + downloadUpdate: () => ipcRenderer.invoke('update-download'), + installUpdate: () => ipcRenderer.invoke('update-install'), + onUpdateStatus: (callback: (status: unknown) => void) => { + const handler = (_event: unknown, status: unknown) => callback(status) + ipcRenderer.on('update-status', handler) + return () => ipcRenderer.removeListener('update-status', handler) + }, + platform: process.platform, }) diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts new file mode 100644 index 00000000..12dde5a3 --- /dev/null +++ b/desktop/src/main/updater.ts @@ -0,0 +1,72 @@ +import { app, BrowserWindow } from 'electron' +import pkg from 'electron-updater' + +// electron-updater ships as CommonJS; grab autoUpdater from the default export. +const { autoUpdater } = pkg + +// 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 + +function send(status: UpdateStatus) { + getWindow()?.webContents.send('update-status', status) +} + +export function initUpdater(windowGetter: () => BrowserWindow | null): void { + getWindow = windowGetter + + // 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) { + 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 + + autoUpdater.on('checking-for-update', () => send({ state: 'checking' })) + autoUpdater.on('update-available', (info) => + send({ state: 'available', version: info.version, notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined }) + ) + autoUpdater.on('update-not-available', () => send({ state: 'not-available' })) + autoUpdater.on('download-progress', (p) => + send({ state: 'downloading', percent: Math.round(p.percent) }) + ) + autoUpdater.on('update-downloaded', (info) => + send({ state: 'downloaded', version: info.version }) + ) + autoUpdater.on('error', (err) => + send({ state: 'error', message: err == null ? 'unknown' : (err.message || String(err)) }) + ) +} + +// Silent check shortly after launch; safe to call when not packaged (no-op). +export function checkForUpdates(): void { + if (!app.isPackaged) return + autoUpdater.checkForUpdates().catch((err) => { + send({ state: 'error', message: err?.message || String(err) }) + }) +} + +export function startDownload(): void { + if (!app.isPackaged) return + autoUpdater.downloadUpdate().catch((err) => { + send({ state: 'error', message: err?.message || String(err) }) + }) +} + +export function quitAndInstall(): void { + if (!app.isPackaged) return + // isSilent=false (show installer), isForceRunAfter=true (relaunch after). + autoUpdater.quitAndInstall(false, true) +} diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index e44a23d0..adfef1bb 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import { useBackend } from './hooks/useBackend' import { usePlatform } from './hooks/usePlatform' import { useUIStore } from './store/uiStore' import { useSessionStore } from './store/sessionStore' +import { initUpdateListener } from './store/updateStore' import apiClient from './api/client' import { t } from './i18n' import ChatPage from './pages/ChatPage' @@ -32,6 +33,9 @@ const App: React.FC = () => { if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl) }, [backend.status, backend.baseUrl]) + // Subscribe to auto-update status from the main process (no-op in dev). + useEffect(() => initUpdateListener(), []) + // Handle app-menu / shortcut actions forwarded from the main process. useEffect(() => { const off = window.electronAPI?.onMenuAction?.((action) => { diff --git a/desktop/src/renderer/src/components/UpdateBanner.tsx b/desktop/src/renderer/src/components/UpdateBanner.tsx new file mode 100644 index 00000000..8c95ac10 --- /dev/null +++ b/desktop/src/renderer/src/components/UpdateBanner.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useState } from 'react' +import { Download, RefreshCw, X, Loader2 } from 'lucide-react' +import { t } from '../i18n' +import { useUpdateStore, hasPendingUpdate } from '../store/updateStore' + +// Compact update panel anchored to the NavRail footer. Only mounts content +// when there's a pending update; otherwise renders nothing so it stays out of +// the way until electron-updater reports a new version. +const UpdateBanner: React.FC = () => { + const state = useUpdateStore() + const [open, setOpen] = useState(false) + + const pending = hasPendingUpdate(state) + const status = state.status + + // Auto-open the panel the moment a new version is first detected. + useEffect(() => { + if (status?.state === 'available') setOpen(true) + }, [status?.state]) + + if (!pending) return null + + const version = state.version + const downloading = status?.state === 'downloading' + const downloaded = status?.state === 'downloaded' + + return ( +
+ {/* Collapsed pill: a red-dotted button that re-opens the panel. */} + {!open && ( + + )} + + {open && ( +
+
+
+

{t('update_available')}

+ {version &&

v{version}

} +
+ +
+ + {downloading && ( +
+
+ + {t('update_downloading')} {state.percent}% +
+
+
+
+
+ )} + + {!downloading && !downloaded && ( + + )} + + {downloaded && ( + + )} +
+ )} +
+ ) +} + +export default UpdateBanner diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index 60426fc4..a786c365 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -31,6 +31,12 @@ const translations: Record> = { knowledge_doc_load_error: '文档加载失败', nav_expand: '展开侧栏', nav_collapse: '收起侧栏', + update_available: '发现新版本', + update_download: '下载更新', + update_downloading: '正在下载', + update_restart: '重启以更新', + update_later: '稍后', + update_latest: '已是最新版本', sessions_title: '会话', session_new: '新对话', session_rename: '重命名', @@ -298,6 +304,12 @@ const translations: Record> = { menu_settings: 'Settings', nav_expand: 'Expand sidebar', nav_collapse: 'Collapse sidebar', + update_available: 'New version available', + update_download: 'Download update', + update_downloading: 'Downloading', + update_restart: 'Restart to update', + update_later: 'Later', + update_latest: 'You are up to date', sessions_title: 'Chats', session_new: 'New chat', session_rename: 'Rename', diff --git a/desktop/src/renderer/src/layout/NavRail.tsx b/desktop/src/renderer/src/layout/NavRail.tsx index 902ad420..189a227d 100644 --- a/desktop/src/renderer/src/layout/NavRail.tsx +++ b/desktop/src/renderer/src/layout/NavRail.tsx @@ -18,6 +18,7 @@ import type { LucideIcon } from 'lucide-react' import { t, getLang, setLang, Lang } from '../i18n' import { useUIStore } from '../store/uiStore' import { useTheme } from '../hooks/useTheme' +import UpdateBanner from '../components/UpdateBanner' interface NavItem { path: string @@ -87,6 +88,11 @@ const NavRail: React.FC = ({ onLangChange }) => { })} + {/* Update banner floats above the footer when a new version is pending */} +
+ {!collapsed && } +
+ {/* Footer actions */}
void + dismiss: () => void + + // Actions proxied to the main process via the preload bridge. + download: () => void + install: () => void +} + +export const useUpdateStore = create((set, get) => ({ + status: null, + version: null, + percent: 0, + dismissedVersion: null, + + setStatus: (s) => + set(() => { + if (s.state === 'available') return { status: s, version: s.version, percent: 0 } + if (s.state === 'downloading') return { status: s, percent: s.percent } + if (s.state === 'downloaded') return { status: s, version: s.version, percent: 100 } + return { status: s } + }), + + dismiss: () => set((st) => ({ dismissedVersion: st.version })), + + download: () => window.electronAPI?.downloadUpdate?.(), + install: () => window.electronAPI?.installUpdate?.(), +})) + +// Subscribe to main-process update events. Returns an unsubscribe fn. +export function initUpdateListener(): (() => void) | undefined { + return window.electronAPI?.onUpdateStatus?.((status) => { + useUpdateStore.getState().setStatus(status as UpdateStatus) + }) +} + +// Whether a new version should be surfaced (available/downloading/downloaded +// and not dismissed for that version). +export function hasPendingUpdate(state: UpdateState): boolean { + const s = state.status + if (!s) return false + const active = s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded' + return active && state.dismissedVersion !== state.version +} diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts index 7ff298b1..3070d938 100644 --- a/desktop/src/renderer/src/types.ts +++ b/desktop/src/renderer/src/types.ts @@ -17,9 +17,23 @@ export interface ElectronAPI { windowIsMaximized: () => Promise onMaximizeChange: (callback: (maximized: boolean) => void) => () => void onMenuAction?: (callback: (action: string) => void) => () => void + // Auto-update + checkForUpdate?: () => Promise + downloadUpdate?: () => Promise + installUpdate?: () => Promise + onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void platform: string } +// Mirrors UpdateStatus in src/main/updater.ts. +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 } + export interface BackendStatusEvent { status: 'ready' | 'error' | 'starting' port?: number