Skip to content

Commit 0e38e44

Browse files
7418claude
andcommitted
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>
1 parent ddb2c00 commit 0e38e44

File tree

11 files changed

+303
-78
lines changed

11 files changed

+303
-78
lines changed

.github/workflows/build.yml

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,57 @@ jobs:
7070

7171
- name: Package for macOS (x64 + arm64)
7272
env:
73-
CSC_IDENTITY_AUTO_DISCOVERY: "false"
73+
CSC_LINK: ${{ secrets.MAC_CERT_P12_BASE64 }}
74+
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }}
75+
APPLE_ID: ${{ secrets.APPLE_ID }}
76+
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
77+
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
7478
run: npx electron-builder --mac --config electron-builder.yml --publish never
7579

80+
- name: Verify code signature
81+
run: |
82+
# Verify every .app bundle produced (arm64 + x64)
83+
APPS=$(find release -name "CodePilot.app" -maxdepth 3)
84+
if [ -z "$APPS" ]; then
85+
echo "::warning::No CodePilot.app found, skipping signature verification"
86+
exit 0
87+
fi
88+
89+
FAILED=0
90+
while IFS= read -r APP_PATH; do
91+
echo "========== Verifying: $APP_PATH =========="
92+
93+
# Display signature details
94+
codesign -dv --verbose=4 "$APP_PATH" 2>&1 | tee /tmp/codesign-info.txt || true
95+
96+
# Assert Developer ID authority
97+
if ! grep -q 'Authority=Developer ID Application' /tmp/codesign-info.txt; then
98+
echo "::error::$APP_PATH is NOT signed with Developer ID Application"
99+
FAILED=1
100+
continue
101+
fi
102+
103+
# Assert correct Team Identifier
104+
if ! grep -q 'TeamIdentifier=K9X599X9Q2' /tmp/codesign-info.txt; then
105+
echo "::error::$APP_PATH has unexpected TeamIdentifier"
106+
FAILED=1
107+
continue
108+
fi
109+
110+
# Strict verification (must pass)
111+
codesign --verify --deep --strict --verbose=4 "$APP_PATH"
112+
113+
# Gatekeeper assessment (best-effort, does not fail the build)
114+
spctl -a -vvv --type execute "$APP_PATH" || echo "::warning::spctl assessment did not pass for $APP_PATH (may be expected in CI)"
115+
116+
echo "✓ $APP_PATH passed all checks"
117+
done <<< "$APPS"
118+
119+
if [ "$FAILED" -ne 0 ]; then
120+
echo "::error::One or more .app bundles failed signature verification"
121+
exit 1
122+
fi
123+
76124
- name: Upload artifacts
77125
uses: actions/upload-artifact@v4
78126
with:

README_CN.md

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

build/entitlements.mac.plist

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.security.cs.allow-jit</key>
6+
<true/>
7+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
8+
<true/>
9+
<key>com.apple.security.cs.disable-library-validation</key>
10+
<true/>
11+
</dict>
12+
</plist>

electron-builder.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ mac:
4848
icon: build/icon.icns
4949
category: public.app-category.developer-tools
5050
artifactName: "${productName}-${version}-${arch}.${ext}"
51+
hardenedRuntime: true
52+
gatekeeperAssess: false
53+
entitlements: build/entitlements.mac.plist
54+
entitlementsInherit: build/entitlements.mac.inherit.plist
55+
notarize: true
5156
target:
5257
- target: dmg
5358
arch: [x64, arm64]

electron/preload.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
2828
bridge: {
2929
isActive: () => ipcRenderer.invoke('bridge:is-active'),
3030
},
31-
// Native auto-updater bridge — disabled (code signature issues on macOS).
32-
// Users are directed to download from GitHub Releases.
33-
// TODO: Re-enable after obtaining Apple Developer certificate.
34-
// updater: {
35-
// checkForUpdates: () => ipcRenderer.invoke('updater:check'),
36-
// downloadUpdate: () => ipcRenderer.invoke('updater:download'),
37-
// quitAndInstall: () => ipcRenderer.invoke('updater:quit-and-install'),
38-
// onStatus: (callback: (data: unknown) => void) => {
39-
// const listener = (_event: unknown, data: unknown) => callback(data);
40-
// ipcRenderer.on('updater:status', listener);
41-
// return () => { ipcRenderer.removeListener('updater:status', listener); };
42-
// },
43-
// },
31+
updater: {
32+
checkForUpdates: () => ipcRenderer.invoke('updater:check'),
33+
downloadUpdate: () => ipcRenderer.invoke('updater:download'),
34+
quitAndInstall: () => ipcRenderer.invoke('updater:quit-and-install'),
35+
onStatus: (callback: (data: unknown) => void) => {
36+
const listener = (_event: unknown, data: unknown) => callback(data);
37+
ipcRenderer.on('updater:status', listener);
38+
return () => { ipcRenderer.removeListener('updater:status', listener); };
39+
},
40+
},
4441
});

electron/updater.ts

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,70 @@
1-
// =============================================================================
2-
// Native auto-updater (electron-updater) — DISABLED
3-
//
4-
// Temporarily disabled due to macOS code signature validation failures with
5-
// ad-hoc signing. Users are directed to download from GitHub Releases instead.
6-
// The browser-mode update check (via /api/app/updates) remains active in the
7-
// frontend to notify users of new versions.
8-
//
9-
// TODO: Re-enable after obtaining an Apple Developer certificate for proper
10-
// code signing, then uncomment this file and the calls in main.ts / preload.ts.
11-
// =============================================================================
12-
13-
// import { autoUpdater } from 'electron-updater';
14-
import type { BrowserWindow } from 'electron';
15-
// import { ipcMain, session } from 'electron';
16-
17-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1+
import { autoUpdater } from 'electron-updater';
2+
import { BrowserWindow, ipcMain } from 'electron';
3+
4+
let win: BrowserWindow | null = null;
5+
6+
function sendStatus(data: {
7+
status: string;
8+
info?: unknown;
9+
progress?: unknown;
10+
error?: string;
11+
}) {
12+
if (win && !win.isDestroyed()) {
13+
win.webContents.send('updater:status', data);
14+
}
15+
}
16+
1817
export function initAutoUpdater(_win: BrowserWindow) {
19-
console.log('[updater] Native auto-updater is disabled. Users should download updates from GitHub Releases.');
18+
win = _win;
19+
20+
autoUpdater.autoDownload = false;
21+
autoUpdater.autoInstallOnAppQuit = true;
22+
23+
autoUpdater.on('checking-for-update', () => {
24+
sendStatus({ status: 'checking' });
25+
});
26+
27+
autoUpdater.on('update-available', (info) => {
28+
sendStatus({ status: 'available', info });
29+
});
30+
31+
autoUpdater.on('update-not-available', (info) => {
32+
sendStatus({ status: 'not-available', info });
33+
});
34+
35+
autoUpdater.on('download-progress', (progress) => {
36+
sendStatus({ status: 'downloading', progress });
37+
});
38+
39+
autoUpdater.on('update-downloaded', (info) => {
40+
sendStatus({ status: 'downloaded', info });
41+
});
42+
43+
autoUpdater.on('error', (err) => {
44+
sendStatus({ status: 'error', error: err?.message ?? String(err) });
45+
});
46+
47+
// IPC handlers
48+
ipcMain.handle('updater:check', async () => {
49+
return autoUpdater.checkForUpdates();
50+
});
51+
52+
ipcMain.handle('updater:download', async () => {
53+
return autoUpdater.downloadUpdate();
54+
});
55+
56+
ipcMain.handle('updater:quit-and-install', () => {
57+
autoUpdater.quitAndInstall();
58+
});
59+
60+
// Delayed initial check (10 seconds after launch)
61+
setTimeout(() => {
62+
autoUpdater.checkForUpdates().catch((err) => {
63+
console.log('[updater] Initial update check failed:', err?.message ?? err);
64+
});
65+
}, 10_000);
2066
}
2167

22-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
2368
export function setUpdaterWindow(_win: BrowserWindow) {
24-
// no-op while native updater is disabled
69+
win = _win;
2570
}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.22.1",
3+
"version": "0.23.0",
44
"private": true,
55
"author": {
66
"name": "op7418",

scripts/after-sign.js

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
11
/* eslint-disable @typescript-eslint/no-require-imports */
22
/**
3-
* electron-builder afterSign hook — ad-hoc code signing for macOS.
3+
* electron-builder afterSign hook — code signing for macOS.
44
*
5-
* electron-updater's ShipIt process validates code signatures when applying
6-
* updates. Without a valid signature the update fails with:
7-
* "Code signature did not pass validation: 代码未能满足指定的代码要求"
5+
* When a real Developer ID certificate is available (CSC_LINK or CSC_NAME env
6+
* vars are set), electron-builder handles signing automatically. This hook only
7+
* runs a strict verification to confirm the signature is intact.
88
*
9-
* The previous approach used `codesign --force --deep -s -`, but --deep is
10-
* unreliable: it does not guarantee correct signing order and may miss nested
11-
* components, causing kSecCSStrictValidate failures.
9+
* When no certificate is available (local dev builds), falls back to ad-hoc
10+
* signing so that electron-updater's ShipIt process can still validate the
11+
* code signature.
1212
*
13-
* This script signs each component individually from the inside out:
13+
* Ad-hoc signing order (inside-out):
1414
* 1. All native binaries (.node, .dylib, .so)
1515
* 2. All Frameworks (*.framework)
1616
* 3. All Helper apps (*.app inside Frameworks/)
1717
* 4. The main .app bundle
18-
*
19-
* Runs in the afterSign hook so it executes AFTER electron-builder's own
20-
* signing step (which is a no-op with CSC_IDENTITY_AUTO_DISCOVERY=false)
21-
* and right before DMG/ZIP artifact creation.
2218
*/
2319
const fs = require('fs');
2420
const path = require('path');
@@ -90,19 +86,55 @@ module.exports = async function afterSign(context) {
9086
const appPath = path.join(appOutDir, `${appName}.app`);
9187

9288
if (!fs.existsSync(appPath)) {
93-
console.warn(`[afterSign] macOS app not found at ${appPath}, skipping ad-hoc signing`);
89+
console.warn(`[afterSign] macOS app not found at ${appPath}, skipping`);
90+
return;
91+
}
92+
93+
// ── Detect real (non-ad-hoc) code signature ───────────────────────────
94+
// Check env vars first (CI path), then probe the actual signature on the
95+
// .app bundle (covers the case where electron-builder auto-discovered a
96+
// Developer ID certificate from the local Keychain).
97+
let hasRealSignature = !!(process.env.CSC_LINK || process.env.CSC_NAME);
98+
99+
if (!hasRealSignature) {
100+
try {
101+
const info = execSync(`codesign -d --verbose=2 "${appPath}" 2>&1`, {
102+
stdio: 'pipe',
103+
timeout: 15000,
104+
encoding: 'utf-8',
105+
});
106+
if (/Authority=Developer ID Application/.test(info)) {
107+
hasRealSignature = true;
108+
}
109+
} catch {
110+
// codesign -d fails if the bundle is unsigned — that's fine
111+
}
112+
}
113+
114+
if (hasRealSignature) {
115+
console.log('[afterSign] Real code signing certificate detected (CSC_LINK/CSC_NAME set or Developer ID signature found).');
116+
console.log('[afterSign] Skipping ad-hoc signing to preserve Developer ID signature.');
117+
118+
try {
119+
execSync(`codesign --verify --deep --strict --verbose=4 "${appPath}"`, {
120+
stdio: 'pipe',
121+
timeout: 60000,
122+
});
123+
console.log('[afterSign] Developer ID signature verification passed.');
124+
} catch (err) {
125+
console.error('[afterSign] WARNING: Developer ID signature verification FAILED:', err.stderr?.toString() || err.message);
126+
}
94127
return;
95128
}
96129

130+
// ── No certificate — ad-hoc signing fallback ─────────────────────────
97131
console.log(`[afterSign] Ad-hoc signing ${appPath} (individual component signing)...`);
98132

99133
const contentsPath = path.join(appPath, 'Contents');
100134
const frameworksPath = path.join(contentsPath, 'Frameworks');
101135
let signed = 0;
102136

103-
// ── Step 1: Sign all native binaries (.node, .dylib, .so) ─────────────
104-
// These are the innermost signable items. Must be signed before their
105-
// enclosing bundles.
137+
// Step 1: Sign all native binaries (.node, .dylib, .so)
106138
const nativeBinaries = collectFiles(contentsPath, ['.node', '.dylib', '.so']);
107139
for (const bin of nativeBinaries) {
108140
codesign(bin);
@@ -112,9 +144,7 @@ module.exports = async function afterSign(context) {
112144
console.log(`[afterSign] Signed ${nativeBinaries.length} native binaries (.node/.dylib/.so)`);
113145
}
114146

115-
// ── Step 2: Sign all Frameworks ───────────────────────────────────────
116-
// Frameworks contain nested code that was already signed in step 1 (if any
117-
// .dylib/.so lived outside the framework) or that --sign covers here.
147+
// Step 2: Sign all Frameworks
118148
const frameworks = collectBundles(frameworksPath, '.framework');
119149
for (const fw of frameworks) {
120150
codesign(fw);
@@ -124,8 +154,7 @@ module.exports = async function afterSign(context) {
124154
console.log(`[afterSign] Signed ${frameworks.length} frameworks`);
125155
}
126156

127-
// ── Step 3: Sign all Helper apps ──────────────────────────────────────
128-
// Electron ships multiple helper apps (GPU, Plugin, Renderer, etc.)
157+
// Step 3: Sign all Helper apps
129158
const helperApps = collectBundles(frameworksPath, '.app');
130159
for (const helper of helperApps) {
131160
codesign(helper);
@@ -135,19 +164,19 @@ module.exports = async function afterSign(context) {
135164
console.log(`[afterSign] Signed ${helperApps.length} helper apps`);
136165
}
137166

138-
// ── Step 4: Sign the main app bundle ──────────────────────────────────
167+
// Step 4: Sign the main app bundle
139168
codesign(appPath);
140169
signed++;
141170

142171
console.log(`[afterSign] Ad-hoc signing complete — ${signed} components signed`);
143172

144-
// ── Verify ────────────────────────────────────────────────────────────
173+
// Verify
145174
try {
146-
execSync(`codesign --verify --strict "${appPath}"`, {
175+
execSync(`codesign --verify --deep --strict "${appPath}"`, {
147176
stdio: 'pipe',
148177
timeout: 30000,
149178
});
150-
console.log('[afterSign] Signature verification passed (--strict)');
179+
console.log('[afterSign] Signature verification passed (--deep --strict)');
151180
} catch (err) {
152181
console.error('[afterSign] WARNING: Signature verification FAILED:', err.stderr?.toString() || err.message);
153182
}

0 commit comments

Comments
 (0)