diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 245422f..d70b51e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/electron-builder.yml b/electron-builder.yml index 9b8c54a..75e0a58 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,5 +1,9 @@ appId: com.codepilot.app productName: CodePilot +publish: + provider: github + owner: op7418 + repo: CodePilot directories: output: release files: diff --git a/electron/main.ts b/electron/main.ts index 819f279..b33888e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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); } diff --git a/electron/preload.ts b/electron/preload.ts index 6de761a..1cd1a89 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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); }; + }, + }, }); diff --git a/electron/updater.ts b/electron/updater.ts new file mode 100644 index 0000000..220b4bf --- /dev/null +++ b/electron/updater.ts @@ -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) { + 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; +} diff --git a/package-lock.json b/package-lock.json index 98d4815..6ffa3f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e4a6c75..cd6f82f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 4c79fb9..5a2c9c4 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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} /> @@ -266,6 +366,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { className="h-5 w-full shrink-0" style={{ WebkitAppRegion: 'drag' } as React.CSSProperties} /> +
{children}
diff --git a/src/components/layout/NavRail.tsx b/src/components/layout/NavRail.tsx index 112eb0d..d56ff79 100644 --- a/src/components/layout/NavRail.tsx +++ b/src/components/layout/NavRail.tsx @@ -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 }: {item.href === "/settings" && hasUpdate && ( - + )} )} diff --git a/src/components/layout/UpdateBanner.tsx b/src/components/layout/UpdateBanner.tsx new file mode 100644 index 0000000..9351344 --- /dev/null +++ b/src/components/layout/UpdateBanner.tsx @@ -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 ( +
+ {t('update.readyToInstall', { version: updateInfo.latestVersion })} + +
+ ); +} diff --git a/src/components/layout/UpdateDialog.tsx b/src/components/layout/UpdateDialog.tsx index c867209..1bebc2c 100644 --- a/src/components/layout/UpdateDialog.tsx +++ b/src/components/layout/UpdateDialog.tsx @@ -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 ( { if (!open) dismissUpdate(); }}> - New Version Available + {t('update.newVersionAvailable')} {updateInfo.releaseName} {updateInfo.publishedAt && ( @@ -64,17 +68,45 @@ export function UpdateDialog() { Current: v{updateInfo.currentVersion} → Latest: v{updateInfo.latestVersion}

+ {/* Download progress bar (native update, downloading) */} + {isNativeUpdate && !readyToInstall && downloadProgress != null && downloadProgress > 0 && ( +
+
+
+
+

+ {t('update.downloading')} {Math.round(downloadProgress)}% +

+
+ )} + - + {!isNativeUpdate ? ( + // Browser mode: open release page + + ) : readyToInstall ? ( + // Electron: downloaded, ready to install + + ) : ( + // Electron: still downloading + + )}
diff --git a/src/components/settings/GeneralSection.tsx b/src/components/settings/GeneralSection.tsx index a8d6d4e..334b893 100644 --- a/src/components/settings/GeneralSection.tsx +++ b/src/components/settings/GeneralSection.tsx @@ -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() {
{updateInfo.updateAvailable ? (
- + - {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 })} - + {updateInfo.readyToInstall ? ( + + ) : !updateInfo.isNativeUpdate ? ( + + ) : null}
) : (

{t('settings.latestVersion')}

diff --git a/src/hooks/useUpdate.ts b/src/hooks/useUpdate.ts index 65f4039..bf9cc1f 100644 --- a/src/hooks/useUpdate.ts +++ b/src/hooks/useUpdate.ts @@ -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(null); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a921863..8ad9cc4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 7e2433d..0f4a1c7 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -296,6 +296,14 @@ const zh: Record = { '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': '跳过危险模式权限提示', diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 204b7e5..ce94b02 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -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; + quitAndInstall: () => Promise; + 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 {