diff --git a/.claude/rules/vm-verification.md b/.claude/rules/vm-verification.md index 6370132..59f97f3 100644 --- a/.claude/rules/vm-verification.md +++ b/.claude/rules/vm-verification.md @@ -26,6 +26,7 @@ self-contained; no user-global skill or rule is required to run it. | CPU / memory profiling (`lyra benchmark`, `sample`) | VM | | Screen resolution change (approximation via Dynamic Resolution) | VM — see note below | | `lyra healthcheck` / API smoke | VM | +| Code signing / Info.plist binding (TCC bundle identity) | VM (`codesign -dvv`, `otool -P`) — see scenario below | | Display hot-plug (external monitor attach / detach) | ScreenProvider fixture + final manual smoke | | NSScreen topology change (`NSApplicationDidChangeScreenParameters`) | ScreenProvider fixture | | Visual overlay pixel verification | Host debug-build lane (`dev-verification.md`) | @@ -100,6 +101,7 @@ $SCRIPT shutdown $VM # graceful guest shutdown | Variable | Default | Purpose | |---|---|---| +| `LYRA_VM_SSH_HOST` | (unset) | Guest IP override — **required for Apple Virtualization backend**, where `utmctl ip-address` is unsupported. Find it via the guest's `/var/db/dhcpd_leases` or `ifconfig`. | | `LYRA_VM_SSH_USER` | `admin` | Guest login name | | `LYRA_VM_SSH_KEY` | `~/.ssh/vm_rsa` | SSH private key path | | `LYRA_VM_SSH_PORT` | `22` | Guest SSH port | @@ -153,6 +155,32 @@ $SCRIPT restore $VM $SCRIPT shutdown $VM ``` +### Code signing / Info.plist binding (TCC bundle identity) + +When a change embeds an `Info.plist` (Mach-O `__TEXT,__info_plist` section) so +TCC can key permission grants by **bundle identity** rather than executable +path (#23), verify the binding inside the guest — a clean macOS install proves +the result without the host's accumulated signing state. + +`swift build -c release` embeds the section but its ad-hoc signature leaves it +**unbound** (`Info.plist=not bound`, `Identifier=`). Only an +explicit `codesign --force --sign -` (what `make install` and CI packaging run) +binds it — codesign then derives `Identifier` from the embedded +`CFBundleIdentifier`. + +```sh +$SCRIPT run-lyra $VM # pushes the release binary +BIN=/tmp/lyra-vm-test/lyra +$SCRIPT exec $VM -- "otool -P $BIN" # section present? CFBundleIdentifier? +$SCRIPT exec $VM -- "codesign -dvv $BIN 2>&1 | grep -E 'Identifier|Info.plist'" # BEFORE: not bound +$SCRIPT exec $VM -- "codesign --force --sign - $BIN && codesign -dvv $BIN 2>&1 | grep -E 'Identifier|Info.plist'" # AFTER: entries=N +``` + +Expected transition: `Identifier=lyra` / `Info.plist=not bound` → +`Identifier=com.generald.lyra` / `Info.plist entries=4`. Re-signing changes the +cdhash, so **restart the daemon** with the bound binary and re-`capture` to +prove it still executes and renders (no-regression). + --- ## Agent rules @@ -168,3 +196,14 @@ $SCRIPT shutdown $VM replace this requirement. - **Restore always runs.** The `restore` subcommand must run even if an intermediate step fails. Use `trap` in any script that calls `run-lyra`. +- **`run-lyra` "daemon crashed at startup" can be a false negative.** The + harness checks `kill -0 $pid` shortly after launch, but the daemon's + first-launch `swift-frontend -interpret` of the MediaRemote helper takes + 1–2 s; a slow guest can trip the check while the process is in fact alive. + Before trusting the `die`, confirm with `$SCRIPT exec $VM -- "pgrep -x lyra"` + and the daemon log — if the PID is alive, proceed. +- **Never run two `run-lyra` concurrently.** Both build under the same + `.build` (SwiftPM serializes with a lock) and both stage into the guest's + `/tmp/lyra-drop`; the second `scp` hits `Permission denied` on the + half-written bundle. Let one finish, or `sudo rm -rf /tmp/lyra-drop` on the + guest before retrying. diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index b992afc..9a1979b 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -64,6 +64,9 @@ jobs: STAGING="lyra-${VERSION}-macos-arm64" mkdir -p "$STAGING" cp "$BUILD_DIR/lyra" "$STAGING/" + # Bind the embedded __info_plist (CFBundleIdentifier) into the ad-hoc + # signature so TCC keys permission grants by bundle identity (#23). + codesign --force --sign - "$STAGING/lyra" find "$BUILD_DIR" -name '*.bundle' -exec cp -R {} "$STAGING/" \; tar czf "${STAGING}.tar.gz" "$STAGING" echo "ARCHIVE=${STAGING}.tar.gz" >> "$GITHUB_ENV" @@ -89,6 +92,9 @@ jobs: STAGING="lyra-${VERSION}-macos-arm64" mkdir -p "$STAGING" cp "$BUILD_DIR/lyra" "$STAGING/" + # Bind the embedded __info_plist (CFBundleIdentifier) into the ad-hoc + # signature so TCC keys permission grants by bundle identity (#23). + codesign --force --sign - "$STAGING/lyra" find "$BUILD_DIR" -name '*.bundle' -exec cp -R {} "$STAGING/" \; tar czf "${STAGING}.tar.gz" "$STAGING" gh release upload "$TAG" "${STAGING}.tar.gz" --clobber diff --git a/Makefile b/Makefile index 6b7a137..9fb0b75 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,10 @@ install: build install -d $(PREFIX)/bin install $(BUILD_DIR)/$(BINARY) $(PREFIX)/bin/$(BINARY) find $(BUILD_DIR) -name '*.bundle' -exec cp -R {} $(PREFIX)/bin/ \; + # Bind the embedded __TEXT,__info_plist (CFBundleIdentifier) into the + # signature. `swift build -c release` leaves it unbound; TCC keys permission + # grants by bundle identity, so the binary needs a re-sign to expose it (#23). + codesign --force --sign - $(PREFIX)/bin/$(BINARY) uninstall: rm -f $(PREFIX)/bin/$(BINARY) diff --git a/Package.swift b/Package.swift index 55f7c56..99a3f61 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,23 @@ let package = Package( "AsyncRunnableCommand", .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Dependencies", package: "swift-dependencies"), + ], + // Info.plist is embedded into the Mach-O __TEXT,__info_plist section + // (see linkerSettings) rather than copied as a bundle resource, so it + // is excluded from SwiftPM's resource processing here. + exclude: ["Info.plist"], + // Embed Info.plist so the binary carries a stable CFBundleIdentifier. + // TCC keys permission grants (e.g. system-audio capture for the planned + // spectrum analyzer, #23) by bundle identity; without an embedded plist + // the grant is keyed to the executable path and resets on every reinstall. + // The path is relative to the package root, where the linker is invoked. + linkerSettings: [ + .unsafeFlags([ + "-Xlinker", "-sectcreate", + "-Xlinker", "__TEXT", + "-Xlinker", "__info_plist", + "-Xlinker", "Sources/CLI/Info.plist", + ]) ] ), diff --git a/Sources/CLI/Info.plist b/Sources/CLI/Info.plist new file mode 100644 index 0000000..70c29e9 --- /dev/null +++ b/Sources/CLI/Info.plist @@ -0,0 +1,14 @@ + + + + + CFBundleIdentifier + com.generald.lyra + CFBundleName + lyra + CFBundleExecutable + lyra + CFBundleInfoDictionaryVersion + 6.0 + + diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 68e69e4..3b1fc79 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.15.0 +2.15.1