mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 03:03:19 +08:00
feat(desktop): add auto-update via electron-updater + manual CI trigger
This commit is contained in:
235
.github/workflows/release.yml
vendored
Normal file
235
.github/workflows/release.yml
vendored
Normal file
@@ -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"
|
||||
102
desktop/package-lock.json
generated
102
desktop/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
72
desktop/src/main/updater.ts
Normal file
72
desktop/src/main/updater.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
97
desktop/src/renderer/src/components/UpdateBanner.tsx
Normal file
97
desktop/src/renderer/src/components/UpdateBanner.tsx
Normal file
@@ -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 (
|
||||
<div className="absolute bottom-14 left-2 right-2 z-40">
|
||||
{/* Collapsed pill: a red-dotted button that re-opens the panel. */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="relative w-full flex items-center gap-2 rounded-btn bg-accent-soft text-accent px-3 py-2 text-[13px] font-medium cursor-pointer hover:bg-accent-soft/80 transition-colors"
|
||||
>
|
||||
<span className="absolute -top-1 -left-1 h-2 w-2 rounded-full bg-danger" />
|
||||
<Download size={15} />
|
||||
<span className="truncate">{t('update_available')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="rounded-lg border border-default bg-elevated shadow-lg p-3 space-y-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold text-content">{t('update_available')}</p>
|
||||
{version && <p className="text-xs text-content-tertiary mt-0.5">v{version}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
state.dismiss()
|
||||
}}
|
||||
className="text-content-tertiary hover:text-content cursor-pointer flex-shrink-0"
|
||||
title={t('update_later')}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{downloading && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-content-secondary">
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
<span>{t('update_downloading')} {state.percent}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-surface-2 overflow-hidden">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${state.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!downloading && !downloaded && (
|
||||
<button
|
||||
onClick={() => state.download()}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<Download size={15} />
|
||||
{t('update_download')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloaded && (
|
||||
<button
|
||||
onClick={() => state.install()}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
{t('update_restart')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateBanner
|
||||
@@ -31,6 +31,12 @@ const translations: Record<string, Record<string, string>> = {
|
||||
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<string, Record<string, string>> = {
|
||||
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',
|
||||
|
||||
@@ -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<NavRailProps> = ({ onLangChange }) => {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Update banner floats above the footer when a new version is pending */}
|
||||
<div className="relative">
|
||||
{!collapsed && <UpdateBanner />}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className={`flex-shrink-0 px-2 py-2 border-t border-subtle ${collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}`}>
|
||||
<FooterBtn
|
||||
|
||||
55
desktop/src/renderer/src/store/updateStore.ts
Normal file
55
desktop/src/renderer/src/store/updateStore.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { create } from 'zustand'
|
||||
import type { UpdateStatus } from '../types'
|
||||
|
||||
interface UpdateState {
|
||||
status: UpdateStatus | null
|
||||
/** Latest available version, kept across download progress updates. */
|
||||
version: string | null
|
||||
/** Download progress 0-100 while state === 'downloading'. */
|
||||
percent: number
|
||||
/** User dismissed the badge for this version (don't nag again until next). */
|
||||
dismissedVersion: string | null
|
||||
|
||||
setStatus: (s: UpdateStatus) => void
|
||||
dismiss: () => void
|
||||
|
||||
// Actions proxied to the main process via the preload bridge.
|
||||
download: () => void
|
||||
install: () => void
|
||||
}
|
||||
|
||||
export const useUpdateStore = create<UpdateState>((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
|
||||
}
|
||||
@@ -17,9 +17,23 @@ export interface ElectronAPI {
|
||||
windowIsMaximized: () => Promise<boolean>
|
||||
onMaximizeChange: (callback: (maximized: boolean) => void) => () => void
|
||||
onMenuAction?: (callback: (action: string) => void) => () => void
|
||||
// Auto-update
|
||||
checkForUpdate?: () => Promise<void>
|
||||
downloadUpdate?: () => Promise<void>
|
||||
installUpdate?: () => Promise<void>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user