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:
7418andClaude Opus 4.6 committed 2026-02-26 11:16:41 +08:00
1 parent 7cd0cb5aab
commit 762953b2ad
16 files changed
+474 -92

No files matched your search

+113 -12
View File
@@ -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>
+6 -2
View File
@@ -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>
)}
+21
View File
@@ -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>
);
}
+42 -10
View File
@@ -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} &rarr; 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>
+26 -11
View File
@@ -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>
+4
View File
@@ -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);
+8
View File
@@ -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',
+8
View File
@@ -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': '跳过危险模式权限提示',
+24
View File
@@ -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 {