name: swift description: Swift project layout + xcodebuild gates for native iOS/macOS apps category: Framework level: library engineer: ui-engineer gate: "xcodebuild build -scheme App" bundles: [] audit-rubric: # TODO: fill in allowed-tools: Read, Glob, Grep, Bash
Swift
Native iOS/macOS/watchOS development for XcodeGen-based projects (the nexus / nx
fleet: a menubar Mac app + iOS + watch + shared framework). Covers project conventions,
headless compile verification from Linux, signing, and the device-deploy gotchas.
XcodeGen projects (project.yml is the source of truth)
nexus.xcodeproj is generated — do not hand-edit project.pbxproj. Targets + sources
live in apps/swift/project.yml:
nexus-ios:
type: application
platform: iOS
sources:
- path: nexus-ios/Sources # DIRECTORY GLOB — any .swift under here auto-includes
- Adding a file = drop it under the globbed
Sources/dir +xcodegen generate. NoPBXFileReference/PBXBuildFile/build-phase surgery. Verify:grep -c MyFile.swift nexus.xcodeproj/project.pbxproj(expect 4 — fileRef, buildFile, group, sources phase). - The
.xcodeprojIS tracked (committed-generated). Afterxcodegen generateadds a file, commit the regenerated pbxproj so teammates who open Xcode without running xcodegen get it. project.pbxprojand*/Generated/Info.plistare XcodeGen output — a dirty diff on them is regen drift, not work. Don't stash/commit-as-precious;xcodegen generateoverwrites them.- Targets in nx:
nexus-ios(iphoneos),nexus-mac(menubar),nexus-watch,NexusShared(shared framework),MarkdownUI. HealthKit/biometrics is iOS-only →nexus-ios.
Headless compile verification (Linux → Mac over SSH)
You cannot run xcodebuild on Linux, but you can verify a self-contained file (imports only
Foundation + a system framework, no app-type deps) against the real SDK over SSH:
scp File.swift mac:/tmp/
ssh mac 'xcrun --sdk iphoneos swiftc -typecheck -target arm64-apple-ios16.0 /tmp/File.swift; echo EXIT=$?'
# EXIT=0 + zero diagnostics = clean. Pass multiple files together to check integration.
ssh mac 'plutil -lint /tmp/Info.plist /tmp/App.entitlements' # validate plists/entitlements
Keep new modules self-contained (e.g. an actor that does its own URLSession POST + endpoint
resolution via Bundle.main.object(forInfoDictionaryKey:)) so they type-check in isolation and
drop in cleanly.
Signing + headless capability registration (no Developer-portal clicking)
- nx uses Automatic signing (
CODE_SIGN_STYLE: Automatic,DEVELOPMENT_TEAM: DX3Y367L2A). - An App Store Connect API key (
~/.appstoreconnect/private_keys/AuthKey_<KEYID>.p8) lets tooling hit Apple's portal with NO interactive Apple-ID/2FA login:xcodebuild ... -allowProvisioningUpdates -authenticationKeyID <id> -authenticationKeyIssuerID <iss> -authenticationKeyPath <p8>auto-registers new capabilities (e.g. HealthKit) on the App ID + regenerates the profile. Adding the entitlement to*.entitlements+ this flag is usually enough — no manual portal step.- fastlane lanes (
sigh_ios,ensure_bundle_id) driveSpaceship::ConnectAPIwith the same .p8 (fastlaneproducedoes NOT support API-key auth; Spaceship does).
- Add a capability = add its key to
<target>/Resources/<target>.entitlements(e.g.com.apple.developer.healthkit+…healthkit.background-delivery) + a usage string inInfo.plist(e.g.NSHealthShareUsageDescription). XcodeGen carries the entitlements path.
THE codesign gotcha: signing needs a GUI (Aqua) session — SOLVED for iOS
codesign with the team identity (8E12…/DX3Y367L2A) fails with errSecInternalComponent
inside a background SSH session (managername=Background). Root cause (nx-tceo6): NOT a locked
keychain — the login keychain is already unlocked in the permanently-logged-in console session. It
is a session-CONTEXT block: the team identity only signs inside the Aqua (GUI) session.
Both macOS and iOS work around this with the gui/501 Aqua bridge: when NOT in Aqua, the
SSH-side script launchctl kickstarts a GUI-scoped LaunchAgent that re-enters the signed build
inside the Aqua session (where signing works), writes an OK/SKIP/FAIL marker, and the SSH
side polls that marker.
- macOS
Nexus.app:deploy/lib/macos-swift-deploy.sh+dev.leonardoacosta.nexus.deploy. - iOS device install (SOLVED):
deploy/lib/ios-device-deploy.sh+deploy/launchagents/dev.leonardoacosta.nexus.ios-deploy.plist. This is no longer a hand-off to Leo — a signed iOS device install runs fully headless overssh mac.
Headless iOS device install over SSH (the working procedure)
# 0. Discover the device UDID (device shows available(paired) when reachable):
ssh mac 'xcrun devicectl list devices'
# 1. One-time: load the GUI LaunchAgent into gui/501 (no sudo — see uid-501 note):
ssh mac '~/dev/nx/deploy/ios-deploy.sh --install' # also run by deploy/install.sh
# 2. Build (signed, in Aqua) + install on the device, headless:
ssh mac '~/dev/nx/deploy/ios-deploy.sh --device <UDID>'
# -> non-Aqua caller kickstarts gui/501/<label>, polls the marker, returns 0 on OK.
# Success prints devicectl's "App installed:" block + bundleID + installationURL.
The bridge internals (clone these conventions for any new GUI-session-bound task): the lib detects
launchctl managername != Aqua, writes the target UDID to a sentinel file
(~/Library/Application Support/Nexus/ios-deploy-device.txt), resets the marker, then
launchctl kickstart -k gui/501/dev.leonardoacosta.nexus.ios-deploy. The agent wrapper
(deploy/lib/ios-deploy-agent.sh) runs INSIDE Aqua: xcodegen generate; signed
xcodebuild build -scheme nexus-ios -destination 'generic/platform=iOS' -allowProvisioningUpdates DEVELOPMENT_TEAM=DX3Y367L2A CODE_SIGN_STYLE=Automatic; locates the .app under
Build/Products/Debug-iphoneos/; xcrun devicectl device install app; best-effort
device process launch; writes the marker. Log: ~/Library/Logs/nexus-ios-deploy.log.
uid-501 / no-sudo note: over
ssh macyou are uid 501, the SAME uid as the console/Aqua user → you canlaunchctl bootstrap/kickstart gui/501/<label>WITHOUT sudo. There is NO passwordless sudo, solaunchctl asuseris NOT an option — the gui/501 kickstart IS the bridge.
Device must be UNLOCKED to LAUNCH (not to install).
devicectl device installsucceeds on a locked phone;device process launchreturnsFBSOpenApplicationErrorDomain error 7 (Locked)until the screen is unlocked. The bridge treats install as the contract and launch as best-effort.
Device install (modern toolchain, raw)
xcrun devicectl list devices # paired devices over coredevice net (no cable)
xcrun devicectl device install app --device <UDID> <Built.app> # install (works on a locked device)
xcrun devicectl device process launch --device <UDID> <bundle.id> # requires the device be unlocked
ios-deploy is legacy and not installed on this Mac — use devicectl. A device shows
available (paired) when reachable; unavailable when off/asleep. The raw commands only sign in
the Aqua session — over SSH, drive them through deploy/ios-deploy.sh (the gui/501 bridge above).
nx quick reference
- Mac over SSH:
ssh mac(config alias; userleonardoacosta, key~/.ssh/id_ed25519), Xcode 26.4, repo at/Users/leonardoacosta/dev/nx. (macbook-pro/wrong-user fails.) - Networking:
Network.postJSON(url:body:)helper; endpoints resolve from anInfo.plistkey with ahttp://homelab:<port>fallback (seeApnsRegistrar— the canonical actor+endpoint pattern to clone for any homelab-push module). - Prefs:
NexusShared.SettingsStore(UserDefaults-backed, typed surface, shared across targets). - iOS app entry:
nexus-ios/Sources/App/NexusIOSApp.swift(@UIApplicationDelegateAdaptorNexusAppDelegate— bootstrap background work fromdidFinishLaunchingWithOptions). - Naming watch-out: nx already uses "health" for SYSTEM metrics (CPU/mem/disk —
HealthSummaryScene/HealthCollector). Apple biometric HealthKit work must be named distinctly (e.g.HealthKit*) to avoid the collision.