diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index 7b6f17dc..1199a84c 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -1,7 +1,7 @@ name: Installer -# Builds the Windows installer from a tag or branch on a GitHub-hosted runner and keeps it as a -# workflow artifact. Started by hand for now; it publishes nothing. Code signing through the +# Builds the Windows installer and the macOS dmg from a tag or branch on GitHub-hosted runners and +# keeps them as workflow artifacts. Started by hand for now; it publishes nothing. Code signing through the # SignPath Foundation needs exactly this: every job up to the signing request on GitHub-hosted # runners, and the unsigned file stored as a workflow artifact first (docs/code-signing-policy.md). # The self-hosted runners of ADR 0022 are therefore not used here. @@ -58,3 +58,66 @@ jobs: apps/desktop/dist/latest.yml if-no-files-found: error retention-days: 14 + + macos: + name: macOS dmg + runs-on: macos-15 + timeout-minutes: 30 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + lfs: true + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + + # The universal app needs the native credential store of both architectures. + - name: Install dependencies + run: pnpm install --frozen-lockfile --cpu=arm64,x64 + + - name: Build the dmg + run: pnpm --filter @partbuddy/desktop dist:mac + + - name: Keep the dmg + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: partbuddy-dmg-macos + path: apps/desktop/dist/PartBuddy-*-mac-universal.dmg + if-no-files-found: error + retention-days: 14 + + # Nobody on the team has a Mac: the runner is the only place that shows whether the app is + # universal, signed ad hoc, carries both credential stores and survives its start. + - name: Check the app + working-directory: apps/desktop/dist/mac-universal + run: | + set -euo pipefail + app=PartBuddy.app + lipo -archs "$app/Contents/MacOS/PartBuddy" | tee /dev/stderr | grep -q 'x86_64 arm64' + codesign --verify --deep --strict --verbose=2 "$app" + codesign -dv "$app" 2>&1 | grep 'Signature=adhoc' + test "$(find "$app" -name 'keyring.darwin-*.node' | tee /dev/stderr | wc -l)" -eq 2 + ls -la "$app/Contents/Resources" | grep -v lproj + test "$(stat -f%z "$app/Contents/Resources/app.asar")" -gt 1000000 + ELECTRON_ENABLE_LOGGING=1 "$app/Contents/MacOS/PartBuddy" > start.log 2>&1 & + pid=$! + sleep 25 + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" && code=0 || code=$? + echo "PartBuddy did not stay running (exit code $code)" + cat start.log + find "$HOME/Library" -newer start.log -type f -not -path '*/Caches/*' 2>/dev/null | head -n 40 + cat "$HOME/Library/Application Support/PartBuddy/logs/partbuddy.log" || true + for report in "$HOME"/Library/Logs/DiagnosticReports/PartBuddy*; do head -n 80 "$report" || true; done + exit 1 + fi + kill "$pid" + tail -n 40 start.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa9d66dc..7632646e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,7 @@ pnpm --filter @partbuddy/desktop dev # Electron with hot reload pnpm --filter @partbuddy/desktop build # bundle into apps/desktop/out pnpm --filter @partbuddy/desktop test:e2e # Electron flows against a fake SolidCraft pnpm --filter @partbuddy/desktop dist # Windows installer in apps/desktop/dist (B10) +pnpm --filter @partbuddy/desktop dist:mac # macOS dmg (universal, signed ad hoc); runs on a Mac only, see installer.yml ``` The app finds a running SolidCraft through its control folder, or starts `solidcraft --control 7878` diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 68dcfb30..302917f1 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -1,5 +1,7 @@ # Windows installer of PartBuddy (plan step B10): NSIS, per user, with Start menu and desktop # shortcuts. Unsigned for now, so SmartScreen warns; signing comes before the public release. +# macOS: a universal dmg as a test build, signed ad hoc only (no Apple developer account yet), so +# Gatekeeper asks once and there are no automatic updates on the Mac. # Updates come from the GitHub releases of the repository (electron-updater). appId: io.github.manuelstaggl.partbuddy productName: PartBuddy @@ -17,6 +19,8 @@ files: - "!node_modules/**" - node_modules/@napi-rs/keyring/** - node_modules/@napi-rs/keyring-win32-x64-msvc/** + - node_modules/@napi-rs/keyring-darwin-arm64/** + - node_modules/@napi-rs/keyring-darwin-x64/** # Licence texts of every bundled library (ADR 0007), written before packing, next to the asar. beforePack: ../../tools/licenses/notices.mjs extraResources: @@ -32,6 +36,24 @@ win: icon: ../../assets/brand/icon/png/partbuddy-256.png # No certificate yet, so nothing is signed; the version and icon are still written into the exe. signAndEditExecutable: true +mac: + target: + - target: dmg + arch: [universal] + # electron-builder makes the icns from this PNG (tools/brand/build.mjs renders it). + icon: ../../assets/brand/icon/png/partbuddy-1024.png + category: public.app-category.graphics-design + # Ad hoc: without any signature Apple Silicon refuses to start the app at all. The hardened + # runtime would need a real certificate to load the credential store. + identity: "-" + hardenedRuntime: false + # Both builds carry both single-architecture credential stores; they are kept, not merged. + x64ArchFiles: "**/keyring.darwin-*.node" + # Merging the two archives left an empty app.asar (installer run 43276112067); they are the same + # JavaScript anyway, so one of them is taken as it is. + mergeASARs: false +dmg: + artifactName: PartBuddy-${version}-mac-universal.${ext} nsis: oneClick: false perMachine: false diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1de1d6b5..9e48104d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,7 +15,8 @@ "test": "vitest run", "test:e2e": "playwright test", "dist": "electron-vite build && electron-builder --win --publish never", - "dist:dir": "electron-vite build && electron-builder --win --dir --publish never" + "dist:dir": "electron-vite build && electron-builder --win --dir --publish never", + "dist:mac": "electron-vite build && electron-builder --mac --publish never" }, "dependencies": { "@ai-sdk/openai": "4.0.106", diff --git a/apps/desktop/src/main/agents/claudeCode.ts b/apps/desktop/src/main/agents/claudeCode.ts index 61b18dc0..65f890aa 100644 --- a/apps/desktop/src/main/agents/claudeCode.ts +++ b/apps/desktop/src/main/agents/claudeCode.ts @@ -32,15 +32,22 @@ export interface ClaudeCommand { prefix: string[]; } -export function findClaudeCode(env: NodeJS.ProcessEnv, home: string, exists: (path: string) => boolean = existsSync): ClaudeCommand | null { +export function findClaudeCode( + env: NodeJS.ProcessEnv, + home: string, + exists: (path: string) => boolean = existsSync, + platform: NodeJS.Platform = process.platform, +): ClaudeCommand | null { if (env.PARTBUDDY_CLAUDE_COMMAND) { // Test hook: a JSON array, for example ["node", "test/fakeClaude.mjs"]. const parts = JSON.parse(env.PARTBUDDY_CLAUDE_COMMAND) as string[]; const [command, ...prefix] = parts; return command ? { command, prefix } : null; } - const name = process.platform === 'win32' ? 'claude.exe' : 'claude'; + const name = platform === 'win32' ? 'claude.exe' : 'claude'; const candidates = [join(home, '.local', 'bin', name)]; + // An app started from the Finder does not get the shell's PATH; Homebrew and npm install here. + if (platform === 'darwin') candidates.push(join('/opt/homebrew/bin', name), join('/usr/local/bin', name)); for (const dir of (env.PATH ?? env.Path ?? '').split(delimiter)) { if (dir) candidates.push(join(dir, name)); } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e889a796..5c7fd942 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -40,7 +40,7 @@ import { SecretStore } from './credentials/secretStore'; import { loadNoteKey } from './notes/noteRecord'; import { NotesService } from './notes/notesService'; import { Bridge, trustedSender } from './bridge'; -import { findExecutable, systemEnvironment } from './solidcraft/discovery'; +import { findExecutable, programInBundle, systemEnvironment } from './solidcraft/discovery'; import { EngineChoice } from './solidcraft/engineChoice'; import { AssistantSettings } from './agents/assistantSettings'; import { checkImportFile, IMPORT_EXTENSIONS, importName, type ImportKind } from './projects/importFile'; @@ -338,13 +338,14 @@ if (!app.requestSingleInstanceLock()) { const options: Electron.OpenDialogOptions = { title: 'SolidCraft', properties: ['openFile'], - filters: process.platform === 'win32' ? [{ name: 'SolidCraft', extensions: ['exe'] }] : [], + filters: process.platform === 'win32' ? [{ name: 'SolidCraft', extensions: ['exe'] }] : process.platform === 'darwin' ? [{ name: 'SolidCraft', extensions: ['app'] }] : [], ...(current ? { defaultPath: current } : {}), }; const picked = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); const [file] = picked.filePaths; if (picked.canceled || !file) return { info: await engineChoice.info(), error: null, cancelled: true }; - return { ...(await engineChoice.choose(file)), cancelled: false }; + // On macOS the dialog hands over the bundle "SolidCraft.app"; the program lies inside it. + return { ...(await engineChoice.choose(programInBundle(file))), cancelled: false }; }); bridge.handle(invokeChannels.appRelaunch, () => { app.relaunch(); diff --git a/apps/desktop/src/main/solidcraft/discovery.ts b/apps/desktop/src/main/solidcraft/discovery.ts index 4e2f80e7..78304391 100644 --- a/apps/desktop/src/main/solidcraft/discovery.ts +++ b/apps/desktop/src/main/solidcraft/discovery.ts @@ -109,12 +109,26 @@ export function locateExecutable( if (root) candidates.push(join(root, 'SolidCraft', name)); } } + if (platform === 'darwin') { + // An app started from the Finder does not get the shell's PATH, so the bundle is looked up directly. + for (const root of ['/Applications', join(environment.home, 'Applications')]) { + candidates.push(programInBundle(join(root, 'SolidCraft.app'))); + } + } for (const dir of (env.PATH ?? env.Path ?? '').split(delimiter)) { if (dir) candidates.push(join(dir, name)); } return { path: candidates.find((path) => exists(path)) ?? null, source: 'automatic' }; } +/** + * The program inside a macOS app bundle (seen in solidcraft-0.40.1-macos-universal.dmg: + * `Contents/MacOS/SolidCraft` with `solidcraft-cli` beside it); any other path stays as it is. + */ +export function programInBundle(path: string): string { + return /\.app[\\/]?$/.test(path) ? join(path, 'Contents', 'MacOS', 'SolidCraft') : path; +} + /** Path of the SolidCraft desktop app, or null when it is not installed. */ export function findExecutable(environment: Environment, exists: (path: string) => boolean = existsSync): string | null { return locateExecutable(environment, exists).path; diff --git a/apps/desktop/src/main/updates.ts b/apps/desktop/src/main/updates.ts index e947d5f4..79c9ed05 100644 --- a/apps/desktop/src/main/updates.ts +++ b/apps/desktop/src/main/updates.ts @@ -28,7 +28,8 @@ export class Updates { /** Whether this build can update itself: the installed app, unless the environment says no. */ get enabled(): boolean { - return app.isPackaged && process.env.PARTBUDDY_UPDATES !== '0'; + // macOS: the test build is signed ad hoc and no release carries a latest-mac.yml, so it cannot update itself. + return app.isPackaged && process.platform !== 'darwin' && process.env.PARTBUDDY_UPDATES !== '0'; } current(): UpdateState { diff --git a/apps/desktop/test/agent.test.ts b/apps/desktop/test/agent.test.ts index 35d5e612..8d06604b 100644 --- a/apps/desktop/test/agent.test.ts +++ b/apps/desktop/test/agent.test.ts @@ -354,6 +354,12 @@ describe('Claude Code adapter', () => { expect(findClaudeCode({}, home, (path) => path === expected)).toEqual({ command: expected, prefix: [] }); expect(findClaudeCode({}, home, () => false)).toBeNull(); }); + + it('finds a Homebrew install on macOS without the shell PATH', () => { + const expected = join('/opt/homebrew/bin', 'claude'); + expect(findClaudeCode({}, tmpdir(), (path) => path === expected, 'darwin')).toEqual({ command: expected, prefix: [] }); + expect(findClaudeCode({}, tmpdir(), (path) => path === expected, 'linux')).toBeNull(); + }); }); describe('agent service with a fake Claude Code', () => { diff --git a/apps/desktop/test/discovery.test.ts b/apps/desktop/test/discovery.test.ts index ebb0704b..c025afaa 100644 --- a/apps/desktop/test/discovery.test.ts +++ b/apps/desktop/test/discovery.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { controlDirectory, findExecutable, findRunning, type Environment } from '../src/main/solidcraft/discovery'; +import { cliBeside, controlDirectory, findExecutable, findRunning, programInBundle, type Environment } from '../src/main/solidcraft/discovery'; const token = 'ab'.repeat(32); const dirs: string[] = []; @@ -82,4 +82,23 @@ describe('SolidCraft discovery', () => { expect(findExecutable({ ...base, env: {} }, exists)).toBeNull(); expect(findExecutable({ ...base, env: { PARTBUDDY_SOLIDCRAFT_EXE: override } }, (p) => p === override)).toBe(override); }); + + it('finds the app bundle on macOS, for all users or in the user folder, with the CLI beside it', () => { + const home = join(tmpdir(), 'partbuddy-home'); + const base = { platform: 'darwin' as const, home, env: {}, isAlive: () => true }; + const system = join('/Applications', 'SolidCraft.app', 'Contents', 'MacOS', 'SolidCraft'); + const own = join(home, 'Applications', 'SolidCraft.app', 'Contents', 'MacOS', 'SolidCraft'); + expect(findExecutable(base, (p) => p === system)).toBe(system); + expect(findExecutable(base, (p) => p === own)).toBe(own); + expect(findExecutable(base, () => false)).toBeNull(); + expect(findExecutable({ ...base, platform: 'linux' }, (p) => p === system)).toBeNull(); + expect(cliBeside(system, 'darwin')).toBe(join('/Applications', 'SolidCraft.app', 'Contents', 'MacOS', 'solidcraft-cli')); + }); + + it('takes a chosen app bundle as the program inside it', () => { + const bundle = join('/Applications', 'SolidCraft.app'); + expect(programInBundle(bundle)).toBe(join(bundle, 'Contents', 'MacOS', 'SolidCraft')); + expect(programInBundle(`${bundle}/`)).toBe(join(bundle, 'Contents', 'MacOS', 'SolidCraft')); + expect(programInBundle(join(bundle, 'Contents', 'MacOS', 'SolidCraft'))).toBe(join(bundle, 'Contents', 'MacOS', 'SolidCraft')); + }); }); diff --git a/assets/brand/icon/png/partbuddy-1024.png b/assets/brand/icon/png/partbuddy-1024.png new file mode 100644 index 00000000..673dcaa2 Binary files /dev/null and b/assets/brand/icon/png/partbuddy-1024.png differ diff --git a/tools/brand/build.mjs b/tools/brand/build.mjs index 53f5ff79..1b731ca5 100644 --- a/tools/brand/build.mjs +++ b/tools/brand/build.mjs @@ -148,7 +148,8 @@ for (const [name, v] of Object.entries(variants)) { } files['svg/wordmark.svg'] = wordmarkSvg(colour.ink, 'PartBuddy'); files['svg/wordmark-light.svg'] = wordmarkSvg(colour.paper, 'PartBuddy'); -const iconSizes = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256]; +// 1024 is the source of the macOS icon; electron-builder makes the icns from it. +const iconSizes = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256, 1024]; for (const size of [16, 20, 24, 32, 48, 256]) files[`svg/app-icon-${size}.svg`] = iconSvg(size); files['favicon/favicon.svg'] = iconSvg(16); files['social/social-preview.svg'] = socialSvg();