Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions .github/workflows/installer.yml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/main/agents/claudeCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/main/solidcraft/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
21 changes: 20 additions & 1 deletion apps/desktop/test/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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'));
});
});
Binary file added assets/brand/icon/png/partbuddy-1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion tools/brand/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading