diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index bfdbe1e..46224b6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -70,9 +70,57 @@ jobs:
- name: Package for macOS (x64 + arm64)
env:
- CSC_IDENTITY_AUTO_DISCOVERY: "false"
+ CSC_LINK: ${{ secrets.MAC_CERT_P12_BASE64 }}
+ CSC_KEY_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npx electron-builder --mac --config electron-builder.yml --publish never
+ - name: Verify code signature
+ run: |
+ # Verify every .app bundle produced (arm64 + x64)
+ APPS=$(find release -name "CodePilot.app" -maxdepth 3)
+ if [ -z "$APPS" ]; then
+ echo "::warning::No CodePilot.app found, skipping signature verification"
+ exit 0
+ fi
+
+ FAILED=0
+ while IFS= read -r APP_PATH; do
+ echo "========== Verifying: $APP_PATH =========="
+
+ # Display signature details
+ codesign -dv --verbose=4 "$APP_PATH" 2>&1 | tee /tmp/codesign-info.txt || true
+
+ # Assert Developer ID authority
+ if ! grep -q 'Authority=Developer ID Application' /tmp/codesign-info.txt; then
+ echo "::error::$APP_PATH is NOT signed with Developer ID Application"
+ FAILED=1
+ continue
+ fi
+
+ # Assert correct Team Identifier
+ if ! grep -q 'TeamIdentifier=K9X599X9Q2' /tmp/codesign-info.txt; then
+ echo "::error::$APP_PATH has unexpected TeamIdentifier"
+ FAILED=1
+ continue
+ fi
+
+ # Strict verification (must pass)
+ codesign --verify --deep --strict --verbose=4 "$APP_PATH"
+
+ # Gatekeeper assessment (best-effort, does not fail the build)
+ spctl -a -vvv --type execute "$APP_PATH" || echo "::warning::spctl assessment did not pass for $APP_PATH (may be expected in CI)"
+
+ echo "✓ $APP_PATH passed all checks"
+ done <<< "$APPS"
+
+ if [ "$FAILED" -ne 0 ]; then
+ echo "::error::One or more .app bundles failed signature verification"
+ exit 1
+ fi
+
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
diff --git a/README_CN.md b/README_CN.md
index 11b7a20..d6ed0da 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -31,7 +31,7 @@
- **自定义技能** -- 定义可复用的提示词技能(全局或项目级别),在聊天中作为斜杠命令调用
- **设置编辑器** -- 可视化和 JSON 编辑器管理 `~/.claude/settings.json`,包括权限和环境变量配置
- **Token 用量追踪** -- 每次助手回复后查看输入/输出 Token 数量和预估费用
-- **自动更新检查** -- 应用定期检查新版本并在有更新时通知你
+- **自动更新** -- 已签名版本支持应用内自动下载和安装更新;未签名本地构建仅支持手动下载
- **深色/浅色主题** -- 导航栏一键切换主题
- **斜杠命令** -- 内置 `/help`、`/clear`、`/cost`、`/compact`、`/doctor`、`/review` 等命令
- **Electron 打包** -- 桌面应用,隐藏标题栏,内置 Next.js 服务器,优雅关闭进程,自动端口分配
diff --git a/build/entitlements.mac.inherit.plist b/build/entitlements.mac.inherit.plist
new file mode 100644
index 0000000..bc43fea
--- /dev/null
+++ b/build/entitlements.mac.inherit.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.inherit
+
+
+
diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist
new file mode 100644
index 0000000..f2eb2ec
--- /dev/null
+++ b/build/entitlements.mac.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/electron-builder.yml b/electron-builder.yml
index 092d9c0..84b53cf 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -48,6 +48,11 @@ mac:
icon: build/icon.icns
category: public.app-category.developer-tools
artifactName: "${productName}-${version}-${arch}.${ext}"
+ hardenedRuntime: true
+ gatekeeperAssess: false
+ entitlements: build/entitlements.mac.plist
+ entitlementsInherit: build/entitlements.mac.inherit.plist
+ notarize: true
target:
- target: dmg
arch: [x64, arm64]
diff --git a/electron/preload.ts b/electron/preload.ts
index 7a49425..8bd37a4 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -28,17 +28,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
bridge: {
isActive: () => ipcRenderer.invoke('bridge:is-active'),
},
- // Native auto-updater bridge — disabled (code signature issues on macOS).
- // Users are directed to download from GitHub Releases.
- // TODO: Re-enable after obtaining Apple Developer certificate.
- // updater: {
- // checkForUpdates: () => ipcRenderer.invoke('updater:check'),
- // downloadUpdate: () => ipcRenderer.invoke('updater:download'),
- // 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); };
- // },
- // },
+ updater: {
+ checkForUpdates: () => ipcRenderer.invoke('updater:check'),
+ downloadUpdate: () => ipcRenderer.invoke('updater:download'),
+ 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
index d13b2d0..8dbb341 100644
--- a/electron/updater.ts
+++ b/electron/updater.ts
@@ -1,25 +1,70 @@
-// =============================================================================
-// Native auto-updater (electron-updater) — DISABLED
-//
-// Temporarily disabled due to macOS code signature validation failures with
-// ad-hoc signing. Users are directed to download from GitHub Releases instead.
-// The browser-mode update check (via /api/app/updates) remains active in the
-// frontend to notify users of new versions.
-//
-// TODO: Re-enable after obtaining an Apple Developer certificate for proper
-// code signing, then uncomment this file and the calls in main.ts / preload.ts.
-// =============================================================================
+import { autoUpdater } from 'electron-updater';
+import { BrowserWindow, ipcMain } from 'electron';
-// import { autoUpdater } from 'electron-updater';
-import type { BrowserWindow } from 'electron';
-// import { ipcMain, session } from 'electron';
+let win: BrowserWindow | null = null;
+
+function sendStatus(data: {
+ status: string;
+ info?: unknown;
+ progress?: unknown;
+ error?: string;
+}) {
+ if (win && !win.isDestroyed()) {
+ win.webContents.send('updater:status', data);
+ }
+}
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function initAutoUpdater(_win: BrowserWindow) {
- console.log('[updater] Native auto-updater is disabled. Users should download updates from GitHub Releases.');
+ win = _win;
+
+ autoUpdater.autoDownload = false;
+ autoUpdater.autoInstallOnAppQuit = true;
+
+ autoUpdater.on('checking-for-update', () => {
+ sendStatus({ status: 'checking' });
+ });
+
+ autoUpdater.on('update-available', (info) => {
+ sendStatus({ status: 'available', info });
+ });
+
+ autoUpdater.on('update-not-available', (info) => {
+ sendStatus({ status: 'not-available', info });
+ });
+
+ autoUpdater.on('download-progress', (progress) => {
+ sendStatus({ status: 'downloading', progress });
+ });
+
+ autoUpdater.on('update-downloaded', (info) => {
+ sendStatus({ status: 'downloaded', info });
+ });
+
+ autoUpdater.on('error', (err) => {
+ sendStatus({ status: 'error', error: err?.message ?? String(err) });
+ });
+
+ // IPC handlers
+ ipcMain.handle('updater:check', async () => {
+ return autoUpdater.checkForUpdates();
+ });
+
+ ipcMain.handle('updater:download', async () => {
+ return autoUpdater.downloadUpdate();
+ });
+
+ ipcMain.handle('updater:quit-and-install', () => {
+ autoUpdater.quitAndInstall();
+ });
+
+ // Delayed initial check (10 seconds after launch)
+ setTimeout(() => {
+ autoUpdater.checkForUpdates().catch((err) => {
+ console.log('[updater] Initial update check failed:', err?.message ?? err);
+ });
+ }, 10_000);
}
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function setUpdaterWindow(_win: BrowserWindow) {
- // no-op while native updater is disabled
+ win = _win;
}
diff --git a/package-lock.json b/package-lock.json
index 715f35b..1f822c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codepilot",
- "version": "0.22.1",
+ "version": "0.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codepilot",
- "version": "0.22.1",
+ "version": "0.23.0",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.47",
"@ai-sdk/google": "^3.0.31",
diff --git a/package.json b/package.json
index 2d475a7..a06cbc0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codepilot",
- "version": "0.22.1",
+ "version": "0.23.0",
"private": true,
"author": {
"name": "op7418",
diff --git a/scripts/after-sign.js b/scripts/after-sign.js
index 3e010ee..57e73a1 100644
--- a/scripts/after-sign.js
+++ b/scripts/after-sign.js
@@ -1,24 +1,20 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/**
- * electron-builder afterSign hook — ad-hoc code signing for macOS.
+ * electron-builder afterSign hook — code signing for macOS.
*
- * electron-updater's ShipIt process validates code signatures when applying
- * updates. Without a valid signature the update fails with:
- * "Code signature did not pass validation: 代码未能满足指定的代码要求"
+ * When a real Developer ID certificate is available (CSC_LINK or CSC_NAME env
+ * vars are set), electron-builder handles signing automatically. This hook only
+ * runs a strict verification to confirm the signature is intact.
*
- * The previous approach used `codesign --force --deep -s -`, but --deep is
- * unreliable: it does not guarantee correct signing order and may miss nested
- * components, causing kSecCSStrictValidate failures.
+ * When no certificate is available (local dev builds), falls back to ad-hoc
+ * signing so that electron-updater's ShipIt process can still validate the
+ * code signature.
*
- * This script signs each component individually from the inside out:
+ * Ad-hoc signing order (inside-out):
* 1. All native binaries (.node, .dylib, .so)
* 2. All Frameworks (*.framework)
* 3. All Helper apps (*.app inside Frameworks/)
* 4. The main .app bundle
- *
- * Runs in the afterSign hook so it executes AFTER electron-builder's own
- * signing step (which is a no-op with CSC_IDENTITY_AUTO_DISCOVERY=false)
- * and right before DMG/ZIP artifact creation.
*/
const fs = require('fs');
const path = require('path');
@@ -90,19 +86,55 @@ module.exports = async function afterSign(context) {
const appPath = path.join(appOutDir, `${appName}.app`);
if (!fs.existsSync(appPath)) {
- console.warn(`[afterSign] macOS app not found at ${appPath}, skipping ad-hoc signing`);
+ console.warn(`[afterSign] macOS app not found at ${appPath}, skipping`);
return;
}
+ // ── Detect real (non-ad-hoc) code signature ───────────────────────────
+ // Check env vars first (CI path), then probe the actual signature on the
+ // .app bundle (covers the case where electron-builder auto-discovered a
+ // Developer ID certificate from the local Keychain).
+ let hasRealSignature = !!(process.env.CSC_LINK || process.env.CSC_NAME);
+
+ if (!hasRealSignature) {
+ try {
+ const info = execSync(`codesign -d --verbose=2 "${appPath}" 2>&1`, {
+ stdio: 'pipe',
+ timeout: 15000,
+ encoding: 'utf-8',
+ });
+ if (/Authority=Developer ID Application/.test(info)) {
+ hasRealSignature = true;
+ }
+ } catch {
+ // codesign -d fails if the bundle is unsigned — that's fine
+ }
+ }
+
+ if (hasRealSignature) {
+ console.log('[afterSign] Real code signing certificate detected (CSC_LINK/CSC_NAME set or Developer ID signature found).');
+ console.log('[afterSign] Skipping ad-hoc signing to preserve Developer ID signature.');
+
+ try {
+ execSync(`codesign --verify --deep --strict --verbose=4 "${appPath}"`, {
+ stdio: 'pipe',
+ timeout: 60000,
+ });
+ console.log('[afterSign] Developer ID signature verification passed.');
+ } catch (err) {
+ console.error('[afterSign] WARNING: Developer ID signature verification FAILED:', err.stderr?.toString() || err.message);
+ }
+ return;
+ }
+
+ // ── No certificate — ad-hoc signing fallback ─────────────────────────
console.log(`[afterSign] Ad-hoc signing ${appPath} (individual component signing)...`);
const contentsPath = path.join(appPath, 'Contents');
const frameworksPath = path.join(contentsPath, 'Frameworks');
let signed = 0;
- // ── Step 1: Sign all native binaries (.node, .dylib, .so) ─────────────
- // These are the innermost signable items. Must be signed before their
- // enclosing bundles.
+ // Step 1: Sign all native binaries (.node, .dylib, .so)
const nativeBinaries = collectFiles(contentsPath, ['.node', '.dylib', '.so']);
for (const bin of nativeBinaries) {
codesign(bin);
@@ -112,9 +144,7 @@ module.exports = async function afterSign(context) {
console.log(`[afterSign] Signed ${nativeBinaries.length} native binaries (.node/.dylib/.so)`);
}
- // ── Step 2: Sign all Frameworks ───────────────────────────────────────
- // Frameworks contain nested code that was already signed in step 1 (if any
- // .dylib/.so lived outside the framework) or that --sign covers here.
+ // Step 2: Sign all Frameworks
const frameworks = collectBundles(frameworksPath, '.framework');
for (const fw of frameworks) {
codesign(fw);
@@ -124,8 +154,7 @@ module.exports = async function afterSign(context) {
console.log(`[afterSign] Signed ${frameworks.length} frameworks`);
}
- // ── Step 3: Sign all Helper apps ──────────────────────────────────────
- // Electron ships multiple helper apps (GPU, Plugin, Renderer, etc.)
+ // Step 3: Sign all Helper apps
const helperApps = collectBundles(frameworksPath, '.app');
for (const helper of helperApps) {
codesign(helper);
@@ -135,19 +164,19 @@ module.exports = async function afterSign(context) {
console.log(`[afterSign] Signed ${helperApps.length} helper apps`);
}
- // ── Step 4: Sign the main app bundle ──────────────────────────────────
+ // Step 4: Sign the main app bundle
codesign(appPath);
signed++;
console.log(`[afterSign] Ad-hoc signing complete — ${signed} components signed`);
- // ── Verify ────────────────────────────────────────────────────────────
+ // Verify
try {
- execSync(`codesign --verify --strict "${appPath}"`, {
+ execSync(`codesign --verify --deep --strict "${appPath}"`, {
stdio: 'pipe',
timeout: 30000,
});
- console.log('[afterSign] Signature verification passed (--strict)');
+ console.log('[afterSign] Signature verification passed (--deep --strict)');
} catch (err) {
console.error('[afterSign] WARNING: Signature verification FAILED:', err.stderr?.toString() || err.message);
}
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
index 58114b2..1d85fb2 100644
--- a/src/components/layout/AppShell.tsx
+++ b/src/components/layout/AppShell.tsx
@@ -378,12 +378,74 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const [checking, setChecking] = useState(false);
const [showDialog, setShowDialog] = useState(false);
- // Native auto-updater disabled — always use browser-mode (GitHub API) check.
- // Users are directed to download from GitHub Releases.
- // TODO: Re-enable native updater after obtaining Apple Developer certificate.
- const isNativeUpdater = false;
+ // Runtime detection: native updater available when running in Electron with updater bridge
+ const isNativeUpdater = typeof window !== "undefined" && !!window.electronAPI?.updater;
- // --- Browser-mode update check ---
+ // --- Native updater status listener ---
+ useEffect(() => {
+ if (!isNativeUpdater) return;
+ const cleanup = window.electronAPI!.updater!.onStatus((event) => {
+ switch (event.status) {
+ case 'available':
+ setUpdateInfo((prev) => ({
+ updateAvailable: true,
+ latestVersion: event.info?.version ?? prev?.latestVersion ?? '',
+ currentVersion: prev?.currentVersion ?? '',
+ releaseName: event.info?.releaseName ?? prev?.releaseName ?? '',
+ releaseNotes: typeof event.info?.releaseNotes === 'string' ? event.info.releaseNotes : prev?.releaseNotes ?? '',
+ releaseUrl: prev?.releaseUrl ?? '',
+ publishedAt: event.info?.releaseDate ?? prev?.publishedAt ?? '',
+ downloadProgress: null,
+ readyToInstall: false,
+ isNativeUpdate: true,
+ lastError: null,
+ }));
+ {
+ const ver = event.info?.version;
+ const dismissed = localStorage.getItem(DISMISSED_VERSION_KEY);
+ if (ver && dismissed !== ver) {
+ setShowDialog(true);
+ }
+ }
+ break;
+ case 'not-available':
+ setUpdateInfo((prev) => prev ? { ...prev, updateAvailable: false, isNativeUpdate: true, lastError: null } : prev);
+ break;
+ case 'downloading':
+ setUpdateInfo((prev) => prev ? {
+ ...prev,
+ downloadProgress: event.progress?.percent ?? prev.downloadProgress,
+ isNativeUpdate: true,
+ lastError: null,
+ } : prev);
+ break;
+ case 'downloaded':
+ setUpdateInfo((prev) => prev ? {
+ ...prev,
+ readyToInstall: true,
+ downloadProgress: 100,
+ isNativeUpdate: true,
+ lastError: null,
+ } : prev);
+ break;
+ case 'error':
+ setUpdateInfo((prev) => prev ? {
+ ...prev,
+ lastError: event.error ?? 'Unknown error',
+ isNativeUpdate: true,
+ } : prev);
+ break;
+ }
+ if (event.status === 'checking') {
+ setChecking(true);
+ } else {
+ setChecking(false);
+ }
+ });
+ return cleanup;
+ }, [isNativeUpdater]);
+
+ // --- Browser-mode update check (fallback for non-Electron) ---
const checkForUpdatesBrowser = useCallback(async () => {
setChecking(true);
try {
@@ -412,29 +474,42 @@ export function AppShell({ children }: { children: React.ReactNode }) {
}
}, []);
- // Native updater check disabled — always use browser-mode check
- const checkForUpdates = checkForUpdatesBrowser;
+ // --- Unified check: native first, browser fallback ---
+ const checkForUpdates = useCallback(async () => {
+ if (isNativeUpdater) {
+ try {
+ await window.electronAPI!.updater!.checkForUpdates();
+ return;
+ } catch {
+ // native check failed, fall through to browser mode
+ }
+ }
+ await checkForUpdatesBrowser();
+ }, [isNativeUpdater, checkForUpdatesBrowser]);
- // Browser mode: check on mount + every 8 hours
+ // Browser mode: periodic check (non-Electron or as fallback)
useEffect(() => {
+ if (isNativeUpdater) return; // native updater handles its own initial check
checkForUpdatesBrowser();
const id = setInterval(checkForUpdatesBrowser, CHECK_INTERVAL);
return () => clearInterval(id);
- }, [checkForUpdatesBrowser]);
+ }, [isNativeUpdater, checkForUpdatesBrowser]);
const dismissUpdate = useCallback(() => {
setShowDialog(false);
}, []);
- // Native download/install disabled — no-ops while native updater is disabled.
- // The UI shows "View Release" (opens GitHub) instead.
const downloadUpdate = useCallback(async () => {
- // no-op: native updater disabled
- }, []);
+ if (isNativeUpdater) {
+ await window.electronAPI!.updater!.downloadUpdate();
+ }
+ }, [isNativeUpdater]);
const quitAndInstall = useCallback(() => {
- // no-op: native updater disabled
- }, []);
+ if (isNativeUpdater) {
+ window.electronAPI!.updater!.quitAndInstall();
+ }
+ }, [isNativeUpdater]);
const updateContextValue = useMemo(
() => ({