feat: integrate electron-updater for in-app auto-update
Replace the manual "jump to GitHub Release" update flow with native in-app auto-update powered by electron-updater. The app now silently downloads updates in the background and prompts the user to restart when ready. Browser mode (`npm run dev`) retains the existing polling fallback via `/api/app/updates`. Main process (electron/updater.ts — new): - Wrap electron-updater's autoUpdater, forward lifecycle events (checking / available / downloading / downloaded / error) to the renderer via IPC channel `updater:status` - Register `updater:check` and `updater:quit-and-install` IPC handlers - Auto-check 10s after launch, then every 4 hours - Only initialized in packaged mode (!isDev) Preload bridge (electron/preload.ts): - Expose `updater` namespace with checkForUpdates, quitAndInstall, and onStatus callback (same pattern as install.onProgress) Type declarations (src/types/electron.d.ts): - Add UpdateStatusEvent, ElectronUpdaterAPI interfaces - Add optional `updater` field to ElectronAPI Update hook (src/hooks/useUpdate.ts): - Extend UpdateInfo with downloadProgress, readyToInstall, isNativeUpdate - Add quitAndInstall to UpdateContextValue Renderer — dual-mode logic (src/components/layout/AppShell.tsx): - Detect `window.electronAPI?.updater` to choose between native IPC subscription (Electron) and HTTP polling (browser) - Map IPC events to UpdateInfo state, including download progress UI components: - UpdateDialog: three footer states — View Release (browser), progress bar + disabled button (downloading), Restart to Update (ready) - UpdateBanner (new): persistent top banner when update is downloaded, shown even after dialog is dismissed - NavRail: settings dot turns green + pulse when readyToInstall - GeneralSection: UpdateCard shows download %, restart button, or View Release depending on mode Build & CI: - electron-builder.yml: add `publish` config (github provider) to generate latest-mac.yml / latest.yml metadata + blockmap files - build.yml: change --publish never to --publish always with GH_TOKEN; release job now uses `gh release edit` instead of create i18n: - Add 6 translation keys (en + zh) for update UI strings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
7cd0cb5aab
commit
762953b2ad
16 files changed
+474
-92
No files matched your search
+21
-51
@@ -39,16 +39,9 @@ jobs:
|
||||
run: npm run electron:build
|
||||
|
||||
- name: Package for Windows
|
||||
run: npx electron-builder --win --config electron-builder.yml --publish never
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: |
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
if-no-files-found: warn
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx electron-builder --win --config electron-builder.yml --publish always
|
||||
|
||||
build-macos:
|
||||
if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'macos' }}
|
||||
@@ -71,14 +64,9 @@ jobs:
|
||||
run: npm run electron:build
|
||||
|
||||
- name: Package for macOS (${{ matrix.arch }})
|
||||
run: npx electron-builder --mac --${{ matrix.arch }} --config electron-builder.yml --publish never
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-${{ matrix.arch }}
|
||||
path: release/*.dmg
|
||||
if-no-files-found: warn
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx electron-builder --mac --${{ matrix.arch }} --config electron-builder.yml --publish always
|
||||
|
||||
build-linux:
|
||||
if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'linux' }}
|
||||
@@ -101,17 +89,9 @@ jobs:
|
||||
run: npm run electron:build
|
||||
|
||||
- name: Package for Linux (${{ matrix.arch }})
|
||||
run: npx electron-builder --linux --${{ matrix.arch }} --config electron-builder.yml --publish never
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-${{ matrix.arch }}
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
if-no-files-found: warn
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx electron-builder --linux --${{ matrix.arch }} --config electron-builder.yml --publish always
|
||||
|
||||
release:
|
||||
if: ${{ always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && !contains(needs.*.result, 'cancelled') }}
|
||||
@@ -122,11 +102,6 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Get version and previous tag
|
||||
id: meta
|
||||
run: |
|
||||
@@ -151,13 +126,7 @@ jobs:
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Collect release files
|
||||
run: |
|
||||
mkdir -p release-files
|
||||
find artifacts -type f \( -name '*.exe' -o -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' -o -name '*.zip' \) -exec cp {} release-files/ \;
|
||||
ls -lh release-files/
|
||||
|
||||
- name: Create GitHub Release
|
||||
- name: Update release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -179,21 +148,21 @@ jobs:
|
||||
|
||||
## Installation
|
||||
|
||||
**Windows**: 下载 exe 安装包,双击安装,按提示完成即可。
|
||||
**Windows**: Download the exe installer, double-click to install.
|
||||
|
||||
**macOS**:
|
||||
1. 下载对应芯片架构的 DMG 文件
|
||||
2. 打开 DMG,将 CodePilot 拖入 Applications 文件夹
|
||||
3. 首次打开时如遇安全提示,前往 **系统设置 → 隐私与安全性** 点击"仍要打开"
|
||||
4. 在 Settings 页面配置 Anthropic API Key 或环境变量
|
||||
1. Download the DMG for your chip architecture
|
||||
2. Open the DMG, drag CodePilot into Applications
|
||||
3. If you see a security prompt on first launch, go to **System Settings > Privacy & Security** and click "Open Anyway"
|
||||
4. Configure your Anthropic API Key in Settings
|
||||
|
||||
**Linux**: 使用 AppImage 直接运行,或通过 deb/rpm 包安装。
|
||||
**Linux**: Run the AppImage directly, or install via deb/rpm package.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10+ / macOS 12.0+ / Linux (glibc 2.31+)
|
||||
- Anthropic API Key 或已配置 `ANTHROPIC_API_KEY` 环境变量
|
||||
- 如需使用代码相关功能,建议安装 Claude Code CLI
|
||||
- Anthropic API Key or `ANTHROPIC_API_KEY` environment variable
|
||||
- Claude Code CLI recommended for code features
|
||||
|
||||
## Changelog
|
||||
NOTES_EOF
|
||||
@@ -207,8 +176,9 @@ jobs:
|
||||
CHANGELOG_INLINE_EOF
|
||||
fi
|
||||
|
||||
gh release create "${GITHUB_REF_NAME}" \
|
||||
# Update the draft release created by electron-builder with full notes
|
||||
gh release edit "${GITHUB_REF_NAME}" \
|
||||
--title "CodePilot v${VERSION}" \
|
||||
--notes-file release-notes.md \
|
||||
--latest \
|
||||
release-files/*
|
||||
--draft=false
|
||||
@@ -1,5 +1,9 @@
|
||||
appId: com.codepilot.app
|
||||
productName: CodePilot
|
||||
publish:
|
||||
provider: github
|
||||
owner: op7418
|
||||
repo: CodePilot
|
||||
directories:
|
||||
output: release
|
||||
files:
|
||||
|
||||
@@ -4,6 +4,7 @@ import { execFileSync, spawn, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import { initAutoUpdater, setUpdaterWindow } from './updater';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let serverProcess: Electron.UtilityProcess | null = null;
|
||||
@@ -739,6 +740,11 @@ app.whenReady().then(async () => {
|
||||
|
||||
serverPort = port;
|
||||
createWindow(port);
|
||||
|
||||
// Initialize auto-updater in packaged mode only
|
||||
if (!isDev && mainWindow) {
|
||||
initAutoUpdater(mainWindow);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start:', err);
|
||||
dialog.showErrorBox(
|
||||
@@ -766,6 +772,11 @@ app.on('activate', async () => {
|
||||
serverPort = port;
|
||||
}
|
||||
createWindow(serverPort || 3000);
|
||||
|
||||
// Re-attach updater to the new window
|
||||
if (!isDev && mainWindow) {
|
||||
setUpdaterWindow(mainWindow);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to restart server:', err);
|
||||
}
|
||||
|
||||
@@ -25,4 +25,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
return () => { ipcRenderer.removeListener('install:progress', listener); };
|
||||
},
|
||||
},
|
||||
updater: {
|
||||
checkForUpdates: () => ipcRenderer.invoke('updater:check'),
|
||||
quitAndInstall: () => ipcRenderer.invoke('updater:quit-and-install'),
|
||||
onStatus: (callback: (data: unknown) => void) => {
|
||||
const listener = (_event: unknown, data: unknown) => callback(data);
|
||||
ipcRenderer.on('updater:status', listener);
|
||||
return () => { ipcRenderer.removeListener('updater:status', listener); };
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function sendStatus(data: Record<string, unknown>) {
|
||||
mainWindow?.webContents.send('updater:status', data);
|
||||
}
|
||||
|
||||
export function initAutoUpdater(win: BrowserWindow) {
|
||||
mainWindow = win;
|
||||
|
||||
// Configuration
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// --- Events ---
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
sendStatus({ status: 'checking' });
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
sendStatus({
|
||||
status: 'available',
|
||||
info: {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
releaseName: info.releaseName,
|
||||
releaseDate: info.releaseDate,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
sendStatus({ status: 'not-available' });
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
sendStatus({
|
||||
status: 'downloading',
|
||||
progress: {
|
||||
percent: progress.percent,
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
sendStatus({
|
||||
status: 'downloaded',
|
||||
info: {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
releaseName: info.releaseName,
|
||||
releaseDate: info.releaseDate,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
sendStatus({ status: 'error', error: err.message });
|
||||
});
|
||||
|
||||
// --- IPC handlers ---
|
||||
ipcMain.handle('updater:check', async () => {
|
||||
return autoUpdater.checkForUpdates();
|
||||
});
|
||||
|
||||
ipcMain.handle('updater:quit-and-install', () => {
|
||||
autoUpdater.quitAndInstall();
|
||||
});
|
||||
|
||||
// Initial check after 10 seconds
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.warn('[updater] Initial check failed:', err.message);
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
// Periodic check every 4 hours
|
||||
setInterval(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.warn('[updater] Periodic check failed:', err.message);
|
||||
});
|
||||
}, 4 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export function setUpdaterWindow(win: BrowserWindow) {
|
||||
mainWindow = win;
|
||||
}
|
||||
Generated
+83
-6
@@ -23,6 +23,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"motion": "^12.33.0",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -8206,7 +8207,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
@@ -8774,7 +8774,6 @@
|
||||
"version": "9.5.1",
|
||||
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz",
|
||||
"integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
@@ -10633,6 +10632,69 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-updater": {
|
||||
"version": "6.8.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz",
|
||||
"integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"builder-util-runtime": "9.5.1",
|
||||
"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/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.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||
"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/electron-winstaller": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
|
||||
@@ -12443,7 +12505,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/hachure-fill": {
|
||||
@@ -13814,7 +13875,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"
|
||||
@@ -14002,7 +14062,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/leva": {
|
||||
@@ -14381,6 +14440,19 @@
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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.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.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -18833,7 +18905,6 @@
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
||||
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
@@ -20035,6 +20106,12 @@
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"motion": "^12.33.0",
|
||||
"nanoid": "^5.1.6",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ChatListPanel } from "./ChatListPanel";
|
||||
import { RightPanel } from "./RightPanel";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
import { UpdateDialog } from "./UpdateDialog";
|
||||
import { UpdateBanner } from "./UpdateBanner";
|
||||
import { DocPreview } from "./DocPreview";
|
||||
import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/usePanel";
|
||||
import { UpdateContext, type UpdateInfo } from "@/hooks/useUpdate";
|
||||
@@ -172,17 +173,26 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
const isNativeUpdater = typeof window !== "undefined" && !!window.electronAPI?.updater;
|
||||
|
||||
// --- Browser-mode update check (fallback) ---
|
||||
const checkForUpdatesBrowser = useCallback(async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch("/api/app/updates");
|
||||
if (!res.ok) return;
|
||||
const data: UpdateInfo = await res.json();
|
||||
setUpdateInfo(data);
|
||||
const data = await res.json();
|
||||
const info: UpdateInfo = {
|
||||
...data,
|
||||
downloadProgress: null,
|
||||
readyToInstall: false,
|
||||
isNativeUpdate: false,
|
||||
};
|
||||
setUpdateInfo(info);
|
||||
|
||||
if (data.updateAvailable) {
|
||||
if (info.updateAvailable) {
|
||||
const dismissed = localStorage.getItem(DISMISSED_VERSION_KEY);
|
||||
if (dismissed !== data.latestVersion) {
|
||||
if (dismissed !== info.latestVersion) {
|
||||
setShowDialog(true);
|
||||
}
|
||||
}
|
||||
@@ -193,6 +203,97 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- Electron native updater check ---
|
||||
const checkForUpdatesNative = useCallback(async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
await window.electronAPI?.updater?.checkForUpdates();
|
||||
} catch {
|
||||
setChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = isNativeUpdater ? checkForUpdatesNative : checkForUpdatesBrowser;
|
||||
|
||||
// Subscribe to native updater IPC events
|
||||
useEffect(() => {
|
||||
if (!isNativeUpdater) return;
|
||||
|
||||
const unsubscribe = window.electronAPI!.updater!.onStatus((event) => {
|
||||
switch (event.status) {
|
||||
case 'checking':
|
||||
setChecking(true);
|
||||
break;
|
||||
|
||||
case 'available':
|
||||
setChecking(false);
|
||||
setUpdateInfo((prev) => {
|
||||
const releaseNotes = typeof event.info?.releaseNotes === 'string'
|
||||
? event.info.releaseNotes
|
||||
: '';
|
||||
const newInfo: UpdateInfo = {
|
||||
updateAvailable: true,
|
||||
latestVersion: event.info?.version ?? '',
|
||||
currentVersion: prev?.currentVersion ?? (process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'),
|
||||
releaseName: event.info?.releaseName ?? `v${event.info?.version}`,
|
||||
releaseNotes,
|
||||
releaseUrl: `https://github.com/op7418/CodePilot/releases/tag/v${event.info?.version}`,
|
||||
publishedAt: event.info?.releaseDate ?? '',
|
||||
downloadProgress: prev?.downloadProgress ?? null,
|
||||
readyToInstall: prev?.readyToInstall ?? false,
|
||||
isNativeUpdate: true,
|
||||
};
|
||||
return newInfo;
|
||||
});
|
||||
// Show dialog if not dismissed
|
||||
if (event.info?.version) {
|
||||
const dismissed = localStorage.getItem(DISMISSED_VERSION_KEY);
|
||||
if (dismissed !== event.info.version) {
|
||||
setShowDialog(true);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'not-available':
|
||||
setChecking(false);
|
||||
break;
|
||||
|
||||
case 'downloading':
|
||||
setUpdateInfo((prev) => prev ? {
|
||||
...prev,
|
||||
downloadProgress: event.progress?.percent ?? null,
|
||||
} : prev);
|
||||
break;
|
||||
|
||||
case 'downloaded':
|
||||
setUpdateInfo((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
readyToInstall: true,
|
||||
downloadProgress: 100,
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setChecking(false);
|
||||
console.warn('[updater] Error:', event.error);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [isNativeUpdater]);
|
||||
|
||||
// Browser mode: check on mount + every 8 hours
|
||||
useEffect(() => {
|
||||
if (isNativeUpdater) return;
|
||||
checkForUpdatesBrowser();
|
||||
const id = setInterval(checkForUpdatesBrowser, CHECK_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [isNativeUpdater, checkForUpdatesBrowser]);
|
||||
|
||||
const dismissUpdate = useCallback(() => {
|
||||
setShowDialog(false);
|
||||
if (updateInfo?.latestVersion) {
|
||||
@@ -200,12 +301,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [updateInfo]);
|
||||
|
||||
// Check on mount + every 8 hours
|
||||
useEffect(() => {
|
||||
checkForUpdates();
|
||||
const id = setInterval(checkForUpdates, CHECK_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [checkForUpdates]);
|
||||
const quitAndInstall = useCallback(() => {
|
||||
window.electronAPI?.updater?.quitAndInstall();
|
||||
}, []);
|
||||
|
||||
const updateContextValue = useMemo(
|
||||
() => ({
|
||||
@@ -215,8 +313,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
dismissUpdate,
|
||||
showDialog,
|
||||
setShowDialog,
|
||||
quitAndInstall,
|
||||
}),
|
||||
[updateInfo, checking, checkForUpdates, dismissUpdate, showDialog]
|
||||
[updateInfo, checking, checkForUpdates, dismissUpdate, showDialog, quitAndInstall]
|
||||
);
|
||||
|
||||
const panelContextValue = useMemo(
|
||||
@@ -252,6 +351,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
chatListOpen={chatListOpen}
|
||||
onToggleChatList={() => setChatListOpen(!chatListOpen)}
|
||||
hasUpdate={updateInfo?.updateAvailable ?? false}
|
||||
readyToInstall={updateInfo?.readyToInstall ?? false}
|
||||
skipPermissionsActive={skipPermissionsActive}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
@@ -266,6 +366,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
className="h-5 w-full shrink-0"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
/>
|
||||
<UpdateBanner />
|
||||
<main className="relative flex-1 overflow-hidden">
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</main>
|
||||
|
||||
@@ -27,6 +27,7 @@ interface NavRailProps {
|
||||
chatListOpen: boolean;
|
||||
onToggleChatList: () => void;
|
||||
hasUpdate?: boolean;
|
||||
readyToInstall?: boolean;
|
||||
skipPermissionsActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -36,7 +37,7 @@ const navItems = [
|
||||
{ href: "/settings", label: "Settings", icon: Settings02Icon },
|
||||
] as const;
|
||||
|
||||
export function NavRail({ onToggleChatList, hasUpdate, skipPermissionsActive }: NavRailProps) {
|
||||
export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermissionsActive }: NavRailProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -103,7 +104,10 @@ export function NavRail({ onToggleChatList, hasUpdate, skipPermissionsActive }:
|
||||
</Link>
|
||||
</Button>
|
||||
{item.href === "/settings" && hasUpdate && (
|
||||
<span className="absolute top-0.5 right-0.5 h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className={cn(
|
||||
"absolute top-0.5 right-0.5 h-2 w-2 rounded-full",
|
||||
readyToInstall ? "bg-green-500 animate-pulse" : "bg-blue-500"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUpdate } from "@/hooks/useUpdate";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
|
||||
export function UpdateBanner() {
|
||||
const { updateInfo, quitAndInstall } = useUpdate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!updateInfo?.isNativeUpdate || !updateInfo.readyToInstall) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 border-b border-blue-500/20 bg-blue-500/10 px-4 py-1.5 text-sm">
|
||||
<span>{t('update.readyToInstall', { version: updateInfo.latestVersion })}</span>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={quitAndInstall}>
|
||||
{t('update.restartNow')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,19 +11,23 @@ import {
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useUpdate } from "@/hooks/useUpdate";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
|
||||
export function UpdateDialog() {
|
||||
const { updateInfo, showDialog, setShowDialog, dismissUpdate } = useUpdate();
|
||||
const { updateInfo, showDialog, dismissUpdate, quitAndInstall } = useUpdate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!updateInfo?.updateAvailable) return null;
|
||||
|
||||
const { isNativeUpdate, readyToInstall, downloadProgress } = updateInfo;
|
||||
|
||||
return (
|
||||
<Dialog open={showDialog} onOpenChange={(open) => {
|
||||
if (!open) dismissUpdate();
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Version Available</DialogTitle>
|
||||
<DialogTitle>{t('update.newVersionAvailable')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{updateInfo.releaseName}
|
||||
{updateInfo.publishedAt && (
|
||||
@@ -64,17 +68,45 @@ export function UpdateDialog() {
|
||||
Current: v{updateInfo.currentVersion} → Latest: v{updateInfo.latestVersion}
|
||||
</p>
|
||||
|
||||
{/* Download progress bar (native update, downloading) */}
|
||||
{isNativeUpdate && !readyToInstall && downloadProgress != null && downloadProgress > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all"
|
||||
style={{ width: `${Math.min(downloadProgress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('update.downloading')} {Math.round(downloadProgress)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={dismissUpdate}>
|
||||
Later
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.open(updateInfo.releaseUrl, "_blank");
|
||||
}}
|
||||
>
|
||||
View Release
|
||||
{t('update.later')}
|
||||
</Button>
|
||||
{!isNativeUpdate ? (
|
||||
// Browser mode: open release page
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.open(updateInfo.releaseUrl, "_blank");
|
||||
}}
|
||||
>
|
||||
{t('settings.viewRelease')}
|
||||
</Button>
|
||||
) : readyToInstall ? (
|
||||
// Electron: downloaded, ready to install
|
||||
<Button onClick={quitAndInstall}>
|
||||
{t('update.restartToUpdate')}
|
||||
</Button>
|
||||
) : (
|
||||
// Electron: still downloading
|
||||
<Button disabled>
|
||||
{t('update.downloading')}...
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
function UpdateCard() {
|
||||
const { updateInfo, checking, checkForUpdates } = useUpdate();
|
||||
const { updateInfo, checking, checkForUpdates, quitAndInstall } = useUpdate();
|
||||
const { t } = useTranslation();
|
||||
const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
|
||||
@@ -52,18 +52,33 @@ function UpdateCard() {
|
||||
<div className="mt-3">
|
||||
{updateInfo.updateAvailable ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-green-500' : 'bg-blue-500'}`} />
|
||||
<span className="text-sm">
|
||||
{t('settings.updateAvailable', { version: updateInfo.latestVersion })}
|
||||
{updateInfo.readyToInstall
|
||||
? t('update.readyToInstall', { version: updateInfo.latestVersion })
|
||||
: updateInfo.downloadProgress != null && updateInfo.downloadProgress > 0 && !updateInfo.readyToInstall
|
||||
? `${t('update.downloading')} ${Math.round(updateInfo.downloadProgress)}%`
|
||||
: t('settings.updateAvailable', { version: updateInfo.latestVersion })}
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-sm"
|
||||
onClick={() => window.open(updateInfo.releaseUrl, "_blank")}
|
||||
>
|
||||
{t('settings.viewRelease')}
|
||||
</Button>
|
||||
{updateInfo.readyToInstall ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-sm"
|
||||
onClick={quitAndInstall}
|
||||
>
|
||||
{t('update.restartToUpdate')}
|
||||
</Button>
|
||||
) : !updateInfo.isNativeUpdate ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-sm"
|
||||
onClick={() => window.open(updateInfo.releaseUrl, "_blank")}
|
||||
>
|
||||
{t('settings.viewRelease')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('settings.latestVersion')}</p>
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface UpdateInfo {
|
||||
releaseNotes: string;
|
||||
releaseUrl: string;
|
||||
publishedAt: string;
|
||||
downloadProgress: number | null;
|
||||
readyToInstall: boolean;
|
||||
isNativeUpdate: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateContextValue {
|
||||
@@ -19,6 +22,7 @@ export interface UpdateContextValue {
|
||||
dismissUpdate: () => void;
|
||||
showDialog: boolean;
|
||||
setShowDialog: (v: boolean) => void;
|
||||
quitAndInstall: () => void;
|
||||
}
|
||||
|
||||
export const UpdateContext = createContext<UpdateContextValue | null>(null);
|
||||
|
||||
@@ -299,6 +299,14 @@ const en = {
|
||||
'error.tryAgain': 'Try Again',
|
||||
'error.reloadApp': 'Reload App',
|
||||
|
||||
// ── Update ─────────────────────────────────────────────────
|
||||
'update.newVersionAvailable': 'New Version Available',
|
||||
'update.downloading': 'Downloading',
|
||||
'update.restartToUpdate': 'Restart to Update',
|
||||
'update.restartNow': 'Restart Now',
|
||||
'update.readyToInstall': 'CodePilot v{version} is ready — restart to update',
|
||||
'update.later': 'Later',
|
||||
|
||||
// ── CLI dynamic field labels ──────────────────────────────
|
||||
'cli.loadingSettings': 'Loading settings...',
|
||||
'cli.field.skipDangerousModePermissionPrompt': 'Skip Dangerous Mode Permission Prompt',
|
||||
|
||||
@@ -296,6 +296,14 @@ const zh: Record<TranslationKey, string> = {
|
||||
'error.tryAgain': '重试',
|
||||
'error.reloadApp': '重新加载',
|
||||
|
||||
// ── Update ─────────────────────────────────────────────────
|
||||
'update.newVersionAvailable': '有新版本可用',
|
||||
'update.downloading': '下载中',
|
||||
'update.restartToUpdate': '重启以更新',
|
||||
'update.restartNow': '立即重启',
|
||||
'update.readyToInstall': 'CodePilot v{version} 已就绪 — 重启以完成更新',
|
||||
'update.later': '稍后',
|
||||
|
||||
// ── CLI dynamic field labels ──────────────────────────────
|
||||
'cli.loadingSettings': '加载设置中...',
|
||||
'cli.field.skipDangerousModePermissionPrompt': '跳过危险模式权限提示',
|
||||
|
||||
Vendored
+24
@@ -17,6 +17,29 @@ interface ElectronInstallAPI {
|
||||
onProgress: (callback: (data: any) => void) => () => void;
|
||||
}
|
||||
|
||||
interface UpdateStatusEvent {
|
||||
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
|
||||
info?: {
|
||||
version: string;
|
||||
releaseNotes?: string | { version: string; note: string }[] | null;
|
||||
releaseName?: string | null;
|
||||
releaseDate?: string;
|
||||
};
|
||||
progress?: {
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ElectronUpdaterAPI {
|
||||
checkForUpdates: () => Promise<unknown>;
|
||||
quitAndInstall: () => Promise<void>;
|
||||
onStatus: (callback: (data: UpdateStatusEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
versions: {
|
||||
electron: string;
|
||||
@@ -33,6 +56,7 @@ interface ElectronAPI {
|
||||
}) => Promise<{ canceled: boolean; filePaths: string[] }>;
|
||||
};
|
||||
install: ElectronInstallAPI;
|
||||
updater?: ElectronUpdaterAPI;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in new issue
Block a user