feat: macOS Developer ID 签名 + notarization + 原生热更新

签名 & 公证:
- build/entitlements.mac.plist, build/entitlements.mac.inherit.plist — Electron hardened runtime 所需权限(JIT、unsigned memory、disable library validation)
- electron-builder.yml — 启用 hardenedRuntime、entitlements、notarize: true
- scripts/after-sign.js — 环境变量 + codesign -d 双重检测,有 Developer ID 时跳过 ad-hoc;无证书时 fallback ad-hoc
- .github/workflows/build.yml — 映射签名 secrets(MAC_CERT_P12_BASE64 等),验证步骤断言 Developer ID Authority + TeamIdentifier,遍历双架构 .app

原生热更新:
- electron/updater.ts — 从 no-op 重写为完整 electron-updater 实现(autoDownload:false, IPC handlers, 启动 10s 延迟检查)
- electron/preload.ts — 恢复 updater IPC bridge
- src/components/layout/AppShell.tsx — 运行时检测 native updater,useEffect 监听 updater:status 事件,native 优先 + browser fallback

其他:
- README_CN.md — 更新自动更新功能描述
- package.json — 版本号升至 0.23.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Sonnet 4.6 committed 2026-03-03 00:46:51 +08:00
1 parent ddb2c00a8c
commit 0e38e4460e
11 files changed
+301 -76

No files matched your search

+49 -1
View File
@@ -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:
+1 -1
View File
@@ -31,7 +31,7 @@
- **自定义技能** -- 定义可复用的提示词技能(全局或项目级别),在聊天中作为斜杠命令调用
- **设置编辑器** -- 可视化和 JSON 编辑器管理 `~/.claude/settings.json`,包括权限和环境变量配置
- **Token 用量追踪** -- 每次助手回复后查看输入/输出 Token 数量和预估费用
- **自动更新检查** -- 应用定期检查新版本并在有更新时通知你
- **自动更新** -- 已签名版本支持应用内自动下载和安装更新;未签名本地构建仅支持手动下载
- **深色/浅色主题** -- 导航栏一键切换主题
- **斜杠命令** -- 内置 `/help`、`/clear`、`/cost`、`/compact`、`/doctor`、`/review` 等命令
- **Electron 打包** -- 桌面应用,隐藏标题栏,内置 Next.js 服务器,优雅关闭进程,自动端口分配
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+5
View File
@@ -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]
+10 -13
View File
@@ -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); };
},
},
});
+63 -18
View File
@@ -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;
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.22.1",
"version": "0.23.0",
"private": true,
"author": {
"name": "op7418",
+54 -25
View File
@@ -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);
}
+90 -15
View File
@@ -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(
() => ({