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( () => ({