From fe77a7a4194824b248384d738cfc6ea30446ba88 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 19 Jan 2026 18:32:09 +0530 Subject: [PATCH] add codesigning to gh action --- .github/workflows/electron-build.yml | 42 +++++++ apps/x/apps/main/agents.md | 16 ++- apps/x/apps/main/forge.config.cjs | 158 ++++++++++++--------------- apps/x/apps/main/src/main.ts | 39 +++++++ 4 files changed, 159 insertions(+), 96 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 1be5ef76..f40e02e2 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -53,11 +53,45 @@ jobs: console.log('Updated version to:', version); " + - name: Import Code Signing Certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + # Create a temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + # Create keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Decode and import certificate + echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + + # Allow codesign to access the keychain + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Add keychain to search list + security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain + + # Verify certificate was imported + security find-identity -v "$KEYCHAIN_PATH" + + # Clean up certificate file + rm -f $RUNNER_TEMP/certificate.p12 + - name: Install dependencies run: pnpm install --frozen-lockfile working-directory: apps/x - name: Build distributables + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: npm run make working-directory: apps/x/apps/main @@ -109,3 +143,11 @@ jobs: files: apps/x/apps/main/out/make/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Cleanup keychain + if: always() + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + if [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi diff --git a/apps/x/apps/main/agents.md b/apps/x/apps/main/agents.md index 5ecd5800..de25ff6c 100644 --- a/apps/x/apps/main/agents.md +++ b/apps/x/apps/main/agents.md @@ -39,16 +39,20 @@ Prepares all build artifacts in a hidden `.package/` staging directory: - Copies preload/renderer dist to `.package/` ### 2. `packageAfterCopy` Hook (Post-copy) -After Forge copies source to output, this hook fixes it: -- Removes unbundled `dist/` (has unresolvable `@x/core` imports) -- Copies bundled `dist-bundle/`, `preload/`, `renderer/` from staging +After Forge copies source to output, this hook replaces source files with bundled/staged files: +- **Hook signature**: `async (config, buildPath, electronVersion, platform, arch)` + - `buildPath` already points to `Contents/Resources/app` (not the `.app` bundle root) +- Removes unbundled `dist/` directory (has unresolvable `@x/core` imports) +- Copies bundled `dist-bundle/` from `.package/` staging directory +- Copies `preload/` and `renderer/` directories from staging - Updates `package.json`: sets `main` to `dist-bundle/main.js`, removes - `"type": "module"` (since we bundle as CJS), removes dependencies -- Cleans up source files (tsconfig.json, src/, etc.) + `"type": "module"` (since we bundle as CJS), removes all dependencies/devDependencies +- Cleans up source files (src/, tsconfig.json, forge.config.cjs, agents.md, .gitignore, bundle.mjs) **Why this approach?** Electron Forge ignores `packagerConfig.dir` and always packages from the config file's directory. The `packageAfterCopy` hook is the -reliable way to customize the packaged output. +reliable way to customize the packaged output by modifying files after Forge +copies the source directory but before the app bundle is finalized. ## Staged Build Directory (`.package/`) diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index b33c697f..dd704045 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -11,6 +11,15 @@ module.exports = { icon: './icons/icon', // .icns extension added automatically appBundleId: 'com.rowboat.app', appCategoryType: 'public.app-category.productivity', + osxSign: {}, + osxNotarize: { + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_PASSWORD, + teamId: process.env.APPLE_TEAM_ID + }, + // NOTE: Electron Forge ignores packagerConfig.dir and always packages from the + // config file's directory. We use packageAfterCopy hook instead to customize output. + // dir: path.join(__dirname, '.package'), // Not supported by Forge // Since we bundle everything with esbuild, we don't need node_modules at all. // These settings prevent Forge's dependency walker (flora-colossus) from trying // to analyze/copy node_modules, which fails with pnpm's symlinked workspaces. @@ -141,113 +150,82 @@ module.exports = { console.log('✅ All assets staged in .package/'); }, - - // Hook runs after Forge copies source to output directory - // We use this to replace the unbundled code with our bundled version - // Hook signature: (forgeConfig, buildPath, electronVersion, platform, arch) - packageAfterCopy: async (forgeConfig, buildPath, electronVersion, platform, arch) => { + // Hook signature: async (config, buildPath, electronVersion, platform, arch) + // Called after Forge copies source directory to build output + // This is where we replace source files with bundled/staged files + packageAfterCopy: async (config, buildPath, electronVersion, platform, arch) => { const fs = require('fs'); const packageDir = path.join(__dirname, '.package'); - - // buildPath is the app directory inside the packaged output - // e.g., out/Rowboat-darwin-arm64/Rowboat.app/Contents/Resources/app - // App bundle root is 3 levels up: buildPath/../../.. = Rowboat.app - const appBundleRoot = path.resolve(buildPath, '../../..'); - console.log('Fixing packaged app at:', buildPath); - console.log('App bundle root:', appBundleRoot); - + // buildPath already points to the app directory (Contents/Resources/app) + const appResourcesPath = buildPath; - // 1. Remove the unbundled dist/ directory (it has imports to @x/core, @x/shared) - const distDir = path.join(buildPath, 'dist'); - if (fs.existsSync(distDir)) { + console.log('📦 Copying staged files from .package/ to packaged app...'); + + // Remove unbundled dist/ directory (source TypeScript output) + const unbundledDist = path.join(appResourcesPath, 'dist'); + if (fs.existsSync(unbundledDist)) { console.log('Removing unbundled dist/...'); - fs.rmSync(distDir, { recursive: true }); + fs.rmSync(unbundledDist, { recursive: true }); } - // 2. Copy the bundled dist-bundle/ from staging - console.log('Copying bundled dist-bundle/...'); - const bundleSrc = path.join(packageDir, 'dist-bundle'); - const bundleDest = path.join(buildPath, 'dist-bundle'); - fs.cpSync(bundleSrc, bundleDest, { recursive: true }); + // Copy bundled dist-bundle/ from staging + const distBundleSrc = path.join(packageDir, 'dist-bundle'); + const distBundleDest = path.join(appResourcesPath, 'dist-bundle'); + if (fs.existsSync(distBundleSrc)) { + console.log('Copying dist-bundle/...'); + fs.mkdirSync(distBundleDest, { recursive: true }); + fs.cpSync(distBundleSrc, distBundleDest, { recursive: true }); + } - // 3. Copy preload from staging - console.log('Copying preload/...'); + // Copy preload/ from staging const preloadSrc = path.join(packageDir, 'preload'); - const preloadDest = path.join(buildPath, 'preload'); - fs.cpSync(preloadSrc, preloadDest, { recursive: true }); + const preloadDest = path.join(appResourcesPath, 'preload'); + if (fs.existsSync(preloadSrc)) { + console.log('Copying preload/...'); + // Remove old preload if it exists + if (fs.existsSync(preloadDest)) { + fs.rmSync(preloadDest, { recursive: true }); + } + fs.cpSync(preloadSrc, preloadDest, { recursive: true }); + } - // 4. Copy renderer from staging - console.log('Copying renderer/...'); + // Copy renderer/ from staging const rendererSrc = path.join(packageDir, 'renderer'); - const rendererDest = path.join(buildPath, 'renderer'); - fs.cpSync(rendererSrc, rendererDest, { recursive: true }); + const rendererDest = path.join(appResourcesPath, 'renderer'); + if (fs.existsSync(rendererSrc)) { + console.log('Copying renderer/...'); + // Remove old renderer if it exists + if (fs.existsSync(rendererDest)) { + fs.rmSync(rendererDest, { recursive: true }); + } + fs.cpSync(rendererSrc, rendererDest, { recursive: true }); + } - // 5. Update package.json to point to bundled entry - console.log('Updating package.json...'); - const packageJsonPath = path.join(buildPath, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); - packageJson.main = 'dist-bundle/main.js'; - // Remove workspace dependencies (they're bundled now) - delete packageJson.dependencies; - delete packageJson.devDependencies; - delete packageJson.scripts; - // Remove "type": "module" - we bundle as CommonJS for compatibility - // with dependencies that use dynamic require() - delete packageJson.type; - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + // Update package.json to point to bundled entry point + const packageJsonPath = path.join(appResourcesPath, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + console.log('Updating package.json...'); + const packageJson = { + name: '@x/main', + version: '0.1.0', + main: 'dist-bundle/main.js', + // Note: No "type": "module" since we bundle as CommonJS + // No dependencies/devDependencies since everything is bundled + }; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + } - // 6. Clean up source files that shouldn't be in production - const filesToRemove = ['tsconfig.json', 'forge.config.cjs', 'agents.md']; + // Clean up source files that shouldn't be in packaged app + const filesToRemove = ['src', 'tsconfig.json', 'forge.config.cjs', 'agents.md', '.gitignore', 'bundle.mjs']; for (const file of filesToRemove) { - const filePath = path.join(buildPath, file); + const filePath = path.join(appResourcesPath, file); if (fs.existsSync(filePath)) { - fs.rmSync(filePath); + console.log(`Removing ${file}...`); + fs.rmSync(filePath, { recursive: true, force: true }); } } - const srcDir = path.join(buildPath, 'src'); - if (fs.existsSync(srcDir)) { - fs.rmSync(srcDir, { recursive: true }); - } - // 7. Remove any signature metadata AFTER all file modifications - // This prevents "code has no resources but signature indicates they must be present" error - // which occurs when _CodeSignature exists but files it references have been moved/modified - const codeSignatureDir = path.join(appBundleRoot, 'Contents', '_CodeSignature'); - if (fs.existsSync(codeSignatureDir)) { - console.log('Removing _CodeSignature directory (files were modified after packaging)...'); - fs.rmSync(codeSignatureDir, { recursive: true }); - } - - // 8. Re-sign the app bundle with adhoc signature after file modifications - // The original bundle signature references resources that we've moved/modified - // Re-signing with adhoc signature creates a new signature that matches the current bundle structure - // This prevents "code has no resources but signature indicates they must be present" error - try { - const { execSync } = require('child_process'); - - // Re-sign the entire app bundle with an adhoc signature - // This creates a fresh signature that matches the modified bundle structure - execSync(`codesign --force --deep --sign - "${appBundleRoot}"`, { stdio: 'ignore' }); - console.log('Re-signed app bundle with adhoc signature (matches modified structure)'); - } catch (e) { - // Ignore errors - codesign might fail, but app should still work - console.log('Warning: Failed to re-sign app bundle:', e.message); - } - - // 9. Clear any signature-related extended attributes - // Even without _CodeSignature or embedded signatures, extended attributes can contain invalid signature metadata - // This prevents "code has no resources but signature indicates they must be present" error - try { - const { execSync } = require('child_process'); - // Clear extended attributes from the entire app bundle - // This removes any signature metadata that might be stored in xattrs - execSync(`xattr -cr "${appBundleRoot}"`, { stdio: 'ignore' }); - console.log('Cleared extended attributes from app bundle'); - } catch (e) { - // Ignore errors - xattr might not be available or might fail silently - } - - console.log('✅ Packaged app fixed with bundled code'); + console.log('✅ Staged files copied to packaged app'); } } }; \ No newline at end of file diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 9c8fb53c..1d78cd35 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; +import { existsSync } from "node:fs"; import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js"; import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js"; import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; @@ -13,6 +14,10 @@ import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// #region agent log +fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:14',message:'__dirname resolved',data:{__dirname,__filename,isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{}); +// #endregion + // Path resolution differs between development and production: // - Development: main.js runs from dist/, preload is at ../../preload/dist/ (sibling dir) // - Production: main.js runs from .package/dist-bundle/, preload is at ../preload/dist/ (copied into .package/) @@ -21,9 +26,17 @@ const preloadPath = app.isPackaged : path.join(__dirname, "../../preload/dist/preload.js"); // Development console.log("preloadPath", preloadPath); +// #region agent log +fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:22',message:'preloadPath computed',data:{preloadPath,exists:existsSync(preloadPath)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{}); +// #endregion + // Register custom protocol for serving built renderer files in production function registerAppProtocol() { protocol.handle('app', (request) => { + // #region agent log + fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:26',message:'protocol handler called',data:{url:request.url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); + // #endregion + // Remove 'app://' prefix and get the path let urlPath = request.url.slice('app://'.length); @@ -45,6 +58,10 @@ function registerAppProtocol() { : path.join(__dirname, '../../renderer/dist'); const filePath = path.join(rendererDistPath, urlPath); + // #region agent log + fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:46',message:'renderer path resolution',data:{rendererDistPath,filePath,urlPath,exists:existsSync(filePath),rendererDistExists:existsSync(rendererDistPath)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{}); + // #endregion + return net.fetch(pathToFileURL(filePath).toString()); }); } @@ -62,9 +79,23 @@ function createWindow() { }, }); + // #region agent log + const loadURL = app.isPackaged ? 'app://./' : 'http://localhost:5173'; + fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:65',message:'createWindow called',data:{isPackaged:app.isPackaged,loadURL,preloadPath},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'E'})}).catch(()=>{}); + // #endregion + if (app.isPackaged) { // Production: load from custom protocol (serves built renderer files) win.loadURL('app://./'); + + // #region agent log + win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { + fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:69',message:'window load failed',data:{errorCode,errorDescription,validatedURL},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); + }); + win.webContents.on('did-finish-load', () => { + fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:72',message:'window load finished',data:{url:win.webContents.getURL()},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); + }); + // #endregion } else { // Development: load from Vite dev server win.loadURL('http://localhost:5173'); @@ -72,9 +103,17 @@ function createWindow() { } app.whenReady().then(() => { + // #region agent log + fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:74',message:'app.whenReady triggered',data:{isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{}); + // #endregion + // Register custom protocol before creating window (for production builds) registerAppProtocol(); + // #region agent log + fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:77',message:'protocol registered',data:{isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); + // #endregion + setupIpcHandlers(); createWindow();