---
name: mac-builder
description: Use when building, testing, running, or deploying the Silo Apple clients (iOS, tvOS, macOS) from the Linux workstation, which has no Xcode — all Apple toolchain work runs on the remote `mac-builder` Mac over SSH and XcodeBuildMCP. Covers syncing the working tree to the Mac, XcodeGen, simulator and device builds, code signing and the keychain trap, reading real build errors, and on-device log capture. Not for editing Swift source (do that locally) and not for server or Android work.
---

# Building the Apple clients on mac-builder

This repo is developed on a Linux host with no Xcode. Every `xcodebuild`, `simctl`, `devicectl`,
and signing operation runs on a remote Mac reached through the SSH alias `mac-builder`.

Read this before the first build in a session. Full background, one-time setup, and the
rationale behind each rule: `docs/mac-builder.md`.

## Two channels, and why the difference matters

1. **XcodeBuildMCP** (`mcp__xcodebuildmcp__*`) — a long-lived stdio MCP server running on the Mac
   over SSH. Structured build/test/device/simulator/UI-automation/LLDB tools. **Prefer these.**
2. **Ad-hoc `ssh mac-builder '<command>'`** — for what MCP doesn't cover: `xcodegen`, keychain
   unlocking, reading raw build logs, `devicectl --console`.

These are **separate SSH sessions**. State set in one does not apply to the other. That single
fact causes the worst failure in this workflow — see [Signing](#signing-and-the-keychain-trap).

If MCP tools are missing entirely, the server isn't connected; fall back to ad-hoc SSH and tell
the user. If only *some* tools exist (simulator but no device/macOS/debug), the wrapper's
`XCODEBUILDMCP_ENABLED_WORKFLOWS` is wrong — see `docs/mac-builder.md`.

## Step 1 — Sync the working tree, and verify it

The Mac has its own checkout at `~/silo-apple-deploy`. It is build scratch. **Never edit source
there, never commit from it.** Local Linux checkout is the source of truth.

A build result from a stale tree is worse than no build. Always sync, always verify.

**Full sync** — first sync of a session, after a branch switch, or after large changes:

```bash
rsync -az --delete --exclude 'DerivedData' --exclude 'build' \
  /srv/dev/github/SiloServer/silo-apple/ mac-builder:~/silo-apple-deploy/
```

**Incremental sync** — the fast path while iterating (seconds, not tens of seconds). Diff against
a *fixed* base commit and reset first, so each push is the full cumulative delta:

```bash
git diff <base-commit> > /tmp/silo.patch
ssh mac-builder 'cd ~/silo-apple-deploy && git checkout -- .'
ssh mac-builder 'cd ~/silo-apple-deploy && git apply - && git status --short' < /tmp/silo.patch
```

**Then verify — do not skip this:**

```bash
ssh mac-builder 'cd ~/silo-apple-deploy && git log --oneline -1 && git status --short | wc -l'
```

The commit must match local `HEAD` and the count must match local `git status --short | wc -l`.
If either differs, fix the sync before building. Do not report a build result from an unverified
tree.

## Step 2 — Regenerate the project

`Silo.xcodeproj` is generated by XcodeGen and is not committed. A stale one silently omits new
files, producing confusing "cannot find type" errors.

```bash
ssh mac-builder 'cd ~/silo-apple-deploy/iosApp && /opt/homebrew/bin/xcodegen generate'
```

Required after any sync touching `project.yml`, adding/removing files, or changing signing
xcconfigs. Cheap — just run it after every sync. Use the absolute path: a non-interactive SSH
session has no Homebrew on `PATH`.

## Step 3 — Session defaults

```
session_show_defaults          # always call before the first build in a session
```

If unset, set them once so later calls take no arguments:

```jsonc
// session_set_defaults
{
  "projectPath": "/Users/<mac-user>/silo-apple-deploy/iosApp/Silo.xcodeproj",
  "scheme": "Silo",
  "simulatorId": "<uuid from list_sims>",
  "configuration": "Debug"
}
```

Get `<mac-user>` from `ssh mac-builder pwd`. Discover IDs with `list_sims` / `list_devices` —
**never hardcode simulator or device UUIDs**; they change on runtime reinstall and re-pairing,
and device IDs are personal hardware identifiers that must not be committed to this public repo.

Schemes: `Silo` (iOS), `SiloTV` (tvOS), `SiloMac` (macOS).

## Building and testing

### Simulator — the default

Needs no developer signing identity, so it never hits the login-keychain trap. Normal simulator
build/run tools still apply Xcode's local ad-hoc signature and simulated entitlements. Keep that
signature for any flow that signs in: Silo stores its session in the Keychain, and an installed
`CODE_SIGNING_ALLOWED=NO` product has no Keychain entitlement, so authentication exists only in
memory and disappears on the next launch.

| Intent | Tool |
|---|---|
| Build | `build_sim` |
| Build and run in one call | `build_run_sim` (prefer over build + launch — fewer SSH round trips) |
| Unit tests | `test_sim` |
| tvOS / macOS | same tools with `{"scheme": "SiloTV"}` / `build_macos`, `test_macos` |
| Screenshot, UI tree | `screenshot`, `snapshot_ui` |
| Interact | `tap`, `type_text`, `swipe`, `button` |
| Debug | `debug_attach_sim`, `debug_breakpoint_add`, `debug_stack`, `debug_variables` |

### Fast compile check, no signing

Quickest confirmation that a platform still builds:

```bash
ssh mac-builder 'cd ~/silo-apple-deploy/iosApp && \
  xcodebuild build -project Silo.xcodeproj -scheme SiloTV \
  -destination "generic/platform=tvOS" CODE_SIGNING_ALLOWED=NO 2>&1 \
  | grep -E "error:|BUILD (SUCCEEDED|FAILED)"'
```

`-scheme SiloMac -destination "platform=macOS"` for the Mac app.

This is **compile-only**. Never install or launch this unsigned product for authenticated UI
validation. Use `build_run_sim` or a normal simulator `xcodebuild` without
`CODE_SIGNING_ALLOWED=NO`, reuse the same simulator, and verify the built app contains simulated
`application-identifier` / `keychain-access-groups` entitlements before asking the user to sign
in. Once authenticated, prefer stop/launch over reinstalling when the binary has not changed.

### Physical device

Requires real signing. Discover the device first:

```bash
ssh mac-builder 'xcrun devicectl list devices'
```

An Apple TV reads `unavailable` while asleep — ask the user to wake it rather than retrying.

Build with the keychain unlock **in the same SSH command** (see below), then:

```bash
ssh mac-builder 'xcrun devicectl device install app --device <DEVICE_ID> \
  ~/silo-build-ios/Build/Products/Debug-iphoneos/Silo.app'
ssh mac-builder 'xcrun devicectl device process launch --device <DEVICE_ID> org.siloserver.silo'
```

Use the stable derived-data paths `~/silo-build-ios` / `~/silo-build-tvos` so incremental builds
stay warm and product paths are predictable. tvOS products land in `Debug-appletvos` — installing
a `Debug-appletvsimulator` bundle fails confusingly, so never mix destinations.

## Signing and the keychain trap

**`security unlock-keychain` does not persist across SSH sessions.** Each connection is its own
security session. Unlocking in one `ssh` call has no effect on the next one, and none on the
already-running MCP server — a different, long-lived session.

Symptom: the build dies in a `CodeSign` step, often on an embedded extension rather than the app.
MCP output shows only "Command CodeSign failed"; the real cause (`User interaction is not
allowed`) is in the raw log.

Rules:

- **Normally signed simulator builds are unaffected.** Xcode uses "Sign to Run Locally" and does
  not need the developer login keychain.
- **`CODE_SIGNING_ALLOWED=NO` is compile-only.** It is safe for build checks, but an installed Silo
  product cannot persist its Keychain-backed login across relaunches.
- **Device builds:** unlock inside the *same* `ssh` command as `xcodebuild`:

```bash
ssh mac-builder 'security unlock-keychain -p "$KEYCHAIN_PASSWORD" ~/Library/Keychains/login.keychain-db && \
  cd ~/silo-apple-deploy/iosApp && \
  xcodebuild build -project Silo.xcodeproj -scheme Silo \
    -destination "id=<DEVICE_ID>" -derivedDataPath ~/silo-build-ios \
    DEVELOPMENT_TEAM=<TEAM_ID> -allowProvisioningUpdates 2>&1 | tail -30'
```

- If codesign still fails right after a successful unlock, the partition list is blocking
  non-interactive access:

```bash
ssh mac-builder 'security unlock-keychain -p "$KEYCHAIN_PASSWORD" ~/Library/Keychains/login.keychain-db && \
  security set-key-partition-list -S apple-tool:,apple:,codesign: -s \
    -k "$KEYCHAIN_PASSWORD" ~/Library/Keychains/login.keychain-db'
```

- Cheap isolation test instead of a five-minute build:

```bash
ssh mac-builder 'security find-identity -v -p codesigning'
ssh mac-builder 'cp /bin/echo /tmp/cs-probe; codesign --force --sign <IDENTITY_HASH> /tmp/cs-probe; rm -f /tmp/cs-probe'
```

Watch for `CSSMERR_TP_CERT_REVOKED` — revoked certs linger in the keychain and get picked ahead
of the good one.

**Secret handling.** The keychain password and team ID are not stored on the Linux host and must
not be written into this repo. Ask the user, or have them unlock the keychain out-of-band. Never
inline a password in a command — agent sessions write full transcripts to disk. If a device build
isn't essential to the task, use a simulator or unsigned build and say so.

Signing *configuration* is in-repo: `iosApp/Signing/Local.xcconfig.sample` → `Local.xcconfig`
(gitignored) overrides bundle IDs, entitlements, and `DEVELOPMENT_TEAM` for Personal Teams. Run
`xcodegen generate` after editing it.

## Reading the real build errors

MCP output is summarized and truncated. Full logs are on the Mac; the tool result gives the exact
path under `~/Library/Developer/XcodeBuildMCP/workspaces/<workspace>-<hash>/logs/`.

```bash
ssh mac-builder 'grep -E "error:|CSSMERR|Command CodeSign" "<log-path>" | head -20'
ssh mac-builder 'grep -B5 -A25 "CodeSign /Users.*<Target>" "<log-path>"'   # signing failures
```

For raw `xcodebuild` output, always filter with `grep -E "error:"` — real errors hide under
thousands of lines of toolchain noise. Do not diagnose from the tail alone.

## Capturing device logs

`OSLog` does **not** reach `devicectl --console`; `print()` to stdout does.

```bash
ssh mac-builder 'nohup xcrun devicectl device process launch \
  --device <DEVICE_ID> --console org.siloserver.silo \
  >> /tmp/silo-run.log 2>&1 </dev/null & disown'
```

All three of `nohup`, `</dev/null`, and `disown` are required, or a SIGHUP / SIGTTIN stop / shell
exit kills the stream.

Tail with `tail -n 0 -F` (capital `-F` survives truncation) piped through `grep --line-buffered`
— without line buffering, events arrive in minute-long bursts.

Closing the `--console` stream **terminates the app on the device**. Never `pkill` that process
mid-test; if you do, you caused the `App terminated due to signal 15` and must relaunch.
`--console` is launch-time only — you cannot attach to an app the user is already using.

## Checklist before reporting a result

- [ ] Synced, and commit + dirty-file count verified against local
- [ ] `xcodegen generate` run after the sync
- [ ] Errors read from the full log, not the truncated tool output
- [ ] Destination matches the claim (simulator build ≠ device verification)
- [ ] Authenticated simulator runs use a normally signed product, not `CODE_SIGNING_ALLOWED=NO`
- [ ] Nothing committed or edited on the Mac

## When the simulator misbehaves, check the disk first

A near-full boot volume on the Mac does not announce itself. It presents as CoreSimulator
corruption, and every symptom points somewhere else:

- `xcrun simctl install` hangs forever on a ~100 MB app.
- A booted device shuts itself down between two commands.
- `simctl boot` reports "delete the device properly or erase contents and settings".
- `ps` / `pgrep` block, because simulator processes are wedged in kernel wait.
- A stray `simctl diagnose` sits holding a device lock — a downstream symptom, not the cause.

```bash
ssh mac-builder 'df -h /System/Volumes/Data'
```

Below ~15 GiB free, expect trouble. `~/Library/Developer/CoreSimulator/Devices` runs to tens
of GB on its own. Reclaim with `xcrun simctl erase <udid>` for devices you aren't using (this
wipes their app data and any signed-in session) and `xcrun simctl delete unavailable`.

`~/silo-build-ios` and `~/silo-build-tvos` are **symlinks** to `/Volumes/NVMe/...`, which has
far more room than the boot volume. The documented paths above still work unchanged — don't
convert them back into real directories on `/`.

## Gotchas

- SourceKit "No such module" errors are IDE index artifacts. If `xcodebuild` says
  `BUILD SUCCEEDED`, the code is fine.
- `mac-builder`'s Tailscale direct path sometimes drops and falls back to a lossy DERP relay.
  SSH then times out while `tailscale ping mac-builder` still answers (via DERP, ~300ms). The
  machine is not down; it recovers on its own. Connecting by raw Tailscale IP may work while
  the hostname does not.
- Device install can fail on a Developer Disk Image mismatch when the device OS is newer than the
  Mac's Xcode. Updating Xcode is the fix; the build isn't wrong.
- Homebrew is not on the non-interactive SSH `PATH`. Use absolute paths:
  `/opt/homebrew/bin/xcodegen`, `/opt/homebrew/bin/xcodebuildmcp`.
- Commits, pushes, and PRs happen on Linux against the real working tree — never from
  `~/silo-apple-deploy`.
- Codex memory may reference a helper at `/home/dev/.local/bin/silo-apple-deploy`. **That script
  no longer exists.** Use the sync steps above.

## Not for this skill

- Editing Swift source — do that in the local checkout, then sync.
- Silo server or Android work.
- Client bugs reproducible without a build.
