diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b4d9a15..2b86c394 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -393,6 +393,24 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain security list-keychains -d user -s build.keychain + - name: Import provisioning profile + if: steps.guard_release_assets.outputs.skip_all != 'true' + env: + APPLE_PROVISION_PROFILE_BASE64: ${{ secrets.APPLE_PROVISION_PROFILE_BASE64 }} + run: | + # Optional secret: only set once a Developer ID provisioning profile exists for + # a restricted entitlement (e.g. CloudKit). See plans/golden-tumbling-gray.md M3 + # for the one-time Developer portal setup this unblocks. Absent today by design — + # when unset, this step is a no-op and the release pipeline (and the signing + # script it feeds) behaves exactly as it does today: no profile, no iCloud. + if [ -z "$APPLE_PROVISION_PROFILE_BASE64" ]; then + echo "No APPLE_PROVISION_PROFILE_BASE64 secret set; skipping profile embedding." + echo "PROGRAMA_PROVISION_PROFILE=" >> "$GITHUB_ENV" + exit 0 + fi + echo "$APPLE_PROVISION_PROFILE_BASE64" | base64 --decode > /tmp/programa.provisionprofile + echo "PROGRAMA_PROVISION_PROFILE=/tmp/programa.provisionprofile" >> "$GITHUB_ENV" + - name: Codesign app if: steps.guard_release_assets.outputs.skip_all != 'true' env: @@ -407,6 +425,7 @@ jobs: HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" ./scripts/sign-release-app.sh "$APP_PATH" "$APPLE_SIGNING_IDENTITY" programa.entitlements ./scripts/verify-release-entitlements.sh "$APP_PATH" "$CLI_PATH" "$HELPER_PATH" + ./scripts/verify-provision-profile.sh "$APP_PATH" - name: Verify embedded Sparkle artifact if: steps.guard_release_assets.outputs.skip_all != 'true' @@ -610,3 +629,4 @@ jobs: run: | security delete-keychain build.keychain >/dev/null 2>&1 || true rm -f /tmp/cert.p12 + rm -f /tmp/programa.provisionprofile diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 40e372b3..2dbfcfa4 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -465,11 +465,36 @@ extension ProgramaCLI { // case that leaves them stuck on. do { let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } - let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + + // Deliberately not `try`, for the same reason as the surfaceId resolution + // further down -- and this was the remaining half of that bug. A throw here + // aborted the hook before any of the three clears below, and the catch at the + // bottom treats teardown-shaped errors as benign: it prints "OK", so Claude + // Code sees a perfectly healthy hook while the red "blocked" badge stays lit + // with nothing left to turn it off. + // + // Nothing recovers from that. There is no TTL or watchdog on agent state, and + // AgentScreenDetectionEngine deliberately refuses to touch a surface a hook has + // claimed (its `hooksOwned` guard), so the terminal can be visibly running a + // command while the sidebar still reads "Claude needs your permission" until the + // session ends. + // + // Falling back to the identifiers the Notification hook recorded is the whole + // point: those are the exact workspace and surface it marked blocked, so they + // are the right things to clear even when a live re-resolution is unavailable. + let resolvedWorkspaceId = (try? resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, client: client - ) + )) + ?? nonEmptyClaudeHookIdentifier(mappedSession?.workspaceId).flatMap { isUUID($0) ? $0 : nil } + ?? nonEmptyClaudeHookIdentifier(workspaceArg).flatMap { isUUID($0) ? $0 : nil } + + // Only when there is genuinely no workspace to name is there nothing to clear. + guard let workspaceId = resolvedWorkspaceId else { + print("OK") + return + } let claudePid = mappedSession?.pid // AskUserQuestion means Claude is about to ask the user something. diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 301acd98..3cfde3f9 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -231,6 +231,7 @@ MOBB0003 /* MobileBridgeStreamSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0004 /* MobileBridgeStreamSupport.swift */; }; MOBB0005 /* MobileBridgeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0006 /* MobileBridgeListener.swift */; }; MOBB0007 /* MobileBridgeSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0008 /* MobileBridgeSession.swift */; }; + MOBB0009 /* MobileBridgePush.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0010 /* MobileBridgePush.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; MOBB1003 /* IrohLib in Frameworks */ = {isa = PBXBuildFile; productRef = MOBB1002 /* IrohLib */; }; @@ -604,6 +605,7 @@ MOBB0004 /* MobileBridgeStreamSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeStreamSupport.swift; sourceTree = ""; }; MOBB0006 /* MobileBridgeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeListener.swift; sourceTree = ""; }; MOBB0008 /* MobileBridgeSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeSession.swift; sourceTree = ""; }; + MOBB0010 /* MobileBridgePush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgePush.swift; sourceTree = ""; }; A5001661 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; @@ -1015,6 +1017,7 @@ MOBB0004 /* MobileBridgeStreamSupport.swift */, MOBB0006 /* MobileBridgeListener.swift */, MOBB0008 /* MobileBridgeSession.swift */, + MOBB0010 /* MobileBridgePush.swift */, RVPN00000000000000000001 /* ReviewComment.swift */, RVPN00000000000000000003 /* ReviewCommentSerializer.swift */, RVPN00000000000000000005 /* ReviewDiffParser.swift */, @@ -1500,6 +1503,7 @@ MOBB0003 /* MobileBridgeStreamSupport.swift in Sources */, MOBB0005 /* MobileBridgeListener.swift in Sources */, MOBB0007 /* MobileBridgeSession.swift in Sources */, + MOBB0009 /* MobileBridgePush.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/MobileBridge/MobileBridgePush.swift b/Sources/MobileBridge/MobileBridgePush.swift new file mode 100644 index 00000000..737b0900 --- /dev/null +++ b/Sources/MobileBridge/MobileBridgePush.swift @@ -0,0 +1,244 @@ +import Bonsplit +import CloudKit +import Foundation + +/// M3: publishes a small, low-sensitivity agent-activity summary to the user's own iCloud +/// private database via CloudKit, so a `CKQuerySubscription` on the paired iPhone can wake it +/// with a push while the mobile bridge's iroh connection is dead (backgrounded/suspended -- +/// see `MobileBridgeListener`). No server Programa operates: each user's Mac writes only to +/// *their own* private database, under container `iCloud.com.darkroom.programa`. The record +/// carries nothing sensitive beyond counts and a workspace title -- never prompt/output text, +/// mirroring the mobile bridge's own "never put prompt or output text in a notification +/// payload" rule (see `plans/golden-tumbling-gray.md`'s "Security implications" section). +/// +/// Gated on: +/// - `MobileBridgeSettings` being anything other than `.off` -- users who never turned on the +/// phone companion generate zero CloudKit traffic. +/// - `CKContainer.accountStatus == .available` -- silently no-ops (logs once) otherwise, e.g. +/// not signed into iCloud on this Mac, or (today) the container entitlement not yet present +/// -- see the macOS entitlements note in this milestone's report. +/// +/// Trigger point: called directly from `Workspace.updatePanelAgentState` / +/// `clearPanelAgentState` / `resetSidebarContext` (`Workspace+SidebarTelemetry.swift`), +/// alongside the existing `SocketEventBroadcaster.shared.publishAgentState(...)` calls at each +/// site. Chosen over adding an observer mechanism to `SocketEventBroadcaster` itself: that +/// class is shared, hot-path infrastructure for the entire v2 subscription fan-out, and giving +/// it a second consumer channel is a materially bigger, riskier change than three one-line +/// calls at the existing telemetry funnel that already fires on every transition. +final class MobileBridgePush: @unchecked Sendable { + static let shared = MobileBridgePush() + + static let containerIdentifier = "iCloud.com.darkroom.programa" + static let recordType = "AgentStatus" + /// Stable record name (default zone, no custom `recordID.zoneID`) so every write updates + /// the same record in place rather than accumulating new ones in the user's iCloud quota. + static let recordName = "agent-status-summary" + + /// Never write more than once every this many seconds -- every CloudKit write is a push + /// delivered to the user's phone. + private static let minimumWriteInterval: TimeInterval = 5 + + struct Summary: Equatable { + var blockedCount: Int + var workingCount: Int + var mostRecentBlockedWorkspaceTitle: String? + } + + private let container: CKContainer + + /// Serializes all mutable state below (`trackedBlockedTitle` through + /// `didLogAccountUnavailable`). Every access to that state -- including from CloudKit's + /// own completion-handler callbacks, which run on an arbitrary system queue -- is + /// dispatched through this queue rather than guarded by a lock, since the coalescing + /// timer (`asyncAfter`) is native to `DispatchQueue` and this avoids mixing a lock with + /// queue-hopping. + private let queue = DispatchQueue(label: "com.darkroom.programa.mobileBridgePush") + + private var trackedBlockedTitle: String? + private var lastWrittenSummary: Summary? + private var pendingSummary: Summary? + private var lastWriteAt: Date = .distantPast + private var coalesceScheduled = false + private var didLogAccountUnavailable = false + + /// **Hard build-time kill switch for the CloudKit entitlement. Set to `true` on + /// 2026-07-28, together with the entitlement itself, once the release pipeline could + /// actually carry a provisioning profile that grants it.** + /// + /// What made it safe to flip: App ID `com.darkroom.programa` was registered with the + /// iCloud capability and container `iCloud.com.darkroom.programa`, a Developer ID + /// Application profile (`Programa Developer ID CloudKit`) was generated against it, and + /// that profile is supplied to CI as `APPLE_PROVISION_PROFILE_BASE64` and embedded by + /// `scripts/sign-release-app.sh` before the app is signed. Verify with + /// `scripts/verify-provision-profile.sh `. + /// + /// If the entitlement is ever removed from `programa.entitlements`, or the profile secret + /// is dropped, set this back to `false` in the same change -- the two must move together. + /// The historical reason, still the reason: + /// + /// Adding `com.apple.developer.icloud-container-identifiers` / + /// `com.apple.developer.icloud-services` to `programa.entitlements` is *not* done as part + /// of this milestone: those are Apple-restricted, App-ID-level capability entitlements + /// that must be present in an embedded provisioning profile to survive AMFI's launch-time + /// check. `scripts/sign-release-app.sh` signs the notarized release build with a bare + /// `codesign --entitlements` pass and embeds no provisioning profile at all -- the exact + /// gap that bricked launch for every user in the 2026-07-14 incident (POSIX 163, see + /// `memory/restricted-entitlements-brick-app.md`) over a different restricted entitlement. + /// `main` auto-ships every green CI run, so there is no safe way to "try it and see"; + /// notarization does not catch this class of failure, only a real launch does. + /// + /// This flag is the second, independent gate (on top of `MobileBridgeSettings` and + /// `CKContainer.accountStatus`) that keeps this file inert in a shipped build even though + /// it already compiles and links against CloudKit: `CKContainer.accountStatus` alone would + /// still report `.available` on any Mac signed into iCloud, entitlement or not, so relying + /// on that gate alone is not sufficient to keep this dark. + static let releaseProvisioningComplete = true + + private init(container: CKContainer = CKContainer(identifier: MobileBridgePush.containerIdentifier)) { + self.container = container + } + + /// Call on every agent-state transition (set or clear) that already calls + /// `SocketEventBroadcaster.shared.publishAgentState`. Must be called from the main thread + /// -- it reads `TabManager.tabs` / `Workspace.aggregateAgentState` synchronously, both of + /// which ARE main-actor isolated (the compiler rejects reading them from a nonisolated + /// context). Annotated `@MainActor` to match; the call sites in + /// `Workspace+SidebarTelemetry.swift` already run there, alongside the existing + /// `publishAgentState` calls, so this adds no hop. Only the derived counts cross to the + /// background queue below -- plain Ints and an optional String. + @MainActor + func noteAgentStateChanged(workspaceId: UUID, workspaceTitle: String, changedState: AgentActivityState?) { + guard Self.releaseProvisioningComplete else { return } + guard Self.bridgeEnabled else { return } + + let workspaces = TerminalController.shared.tabManager?.tabs ?? [] + var blockedCount = 0 + var workingCount = 0 + for workspace in workspaces { + switch workspace.aggregateAgentState { + case .blocked: blockedCount += 1 + case .working: workingCount += 1 + case .idle, nil: break + } + } + let newlyBlockedWorkspaceTitle = (changedState == .blocked) ? workspaceTitle : nil + + queue.async { [weak self] in + self?.handleChange( + blockedCount: blockedCount, + workingCount: workingCount, + newlyBlockedWorkspaceTitle: newlyBlockedWorkspaceTitle + ) + } + } + + private static var bridgeEnabled: Bool { + let raw = UserDefaults.standard.string(forKey: MobileBridgeSettings.appStorageKey) + ?? MobileBridgeSettings.defaultMode.rawValue + return MobileBridgeSettings.mode(for: raw) != .off + } + + // MARK: - `queue`-confined + + private func handleChange(blockedCount: Int, workingCount: Int, newlyBlockedWorkspaceTitle: String?) { + if let newlyBlockedWorkspaceTitle { + trackedBlockedTitle = newlyBlockedWorkspaceTitle + } + if blockedCount == 0 { + trackedBlockedTitle = nil + } + + let summary = Summary( + blockedCount: blockedCount, + workingCount: workingCount, + mostRecentBlockedWorkspaceTitle: trackedBlockedTitle + ) + guard summary != lastWrittenSummary, summary != pendingSummary else { return } + pendingSummary = summary + scheduleCoalescedWriteIfNeeded() + } + + private func scheduleCoalescedWriteIfNeeded() { + guard !coalesceScheduled else { return } + coalesceScheduled = true + let elapsed = Date().timeIntervalSince(lastWriteAt) + let delay = max(0, Self.minimumWriteInterval - elapsed) + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.flushPendingWrite() + } + } + + private func flushPendingWrite() { + coalesceScheduled = false + guard let summary = pendingSummary, summary != lastWrittenSummary else { + pendingSummary = nil + return + } + pendingSummary = nil + lastWriteAt = Date() + checkAccountStatusAndSave(summary) + } + + private func checkAccountStatusAndSave(_ summary: Summary) { + container.accountStatus { [weak self] status, error in + guard let self else { return } + self.queue.async { + guard status == .available, error == nil else { + if !self.didLogAccountUnavailable { + self.didLogAccountUnavailable = true +#if DEBUG + dlog( + "mobileBridge.push.accountUnavailable status=\(status.rawValue) " + + "error=\(String(describing: error))" + ) +#endif + } + return + } + self.didLogAccountUnavailable = false + self.performSave(summary) + } + } + } + + /// Runs on `queue`. Always constructs a fresh `CKRecord` (never fetches first) and saves + /// with `.changedKeys` -- correct for this record's single-writer-per-account semantics + /// (only this Mac, under this iCloud account, ever writes `agent-status-summary`), and + /// avoids the `.ifServerRecordUnchanged` default's change-tag conflict since we never hold + /// a server-issued tag. + private func performSave(_ summary: Summary) { + let recordID = CKRecord.ID(recordName: Self.recordName) + let record = CKRecord(recordType: Self.recordType, recordID: recordID) + record["blockedCount"] = summary.blockedCount as CKRecordValue + record["workingCount"] = summary.workingCount as CKRecordValue + if let title = summary.mostRecentBlockedWorkspaceTitle { + record["mostRecentBlockedWorkspaceTitle"] = title as CKRecordValue + } else { + // Explicit nil clears the field server-side (and counts as a "changed key") -- + // omitting the key entirely would leave a stale title from a previous block. + record["mostRecentBlockedWorkspaceTitle"] = nil + } + + let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil) + operation.savePolicy = .changedKeys + operation.qualityOfService = .utility + operation.modifyRecordsResultBlock = { [weak self] result in + guard let self else { return } + self.queue.async { + switch result { + case .success: + self.lastWrittenSummary = summary +#if DEBUG + dlog( + "mobileBridge.push.wrote blocked=\(summary.blockedCount) " + + "working=\(summary.workingCount)" + ) +#endif + case let .failure(error): + NSLog("MobileBridgePush: CloudKit write failed: %@", "\(error)") + } + } + } + container.privateCloudDatabase.add(operation) + } +} diff --git a/Sources/Workspace+SidebarTelemetry.swift b/Sources/Workspace+SidebarTelemetry.swift index dc6fd436..1104cef8 100644 --- a/Sources/Workspace+SidebarTelemetry.swift +++ b/Sources/Workspace+SidebarTelemetry.swift @@ -189,6 +189,7 @@ extension Workspace { // this is the single safe place to fire from. AgentStateWaitRegistry.shared.notify(surfaceId: panelId, newState: state, source: source) SocketEventBroadcaster.shared.publishAgentState(workspaceId: id, surfaceId: panelId, state: state, source: source) + MobileBridgePush.shared.noteAgentStateChanged(workspaceId: id, workspaceTitle: title, changedState: state) #if DEBUG dlog( "surface.agentState workspace=\(id.uuidString.prefix(5)) " + @@ -203,6 +204,7 @@ extension Workspace { panelAgentStateSources.removeValue(forKey: panelId) AgentStateWaitRegistry.shared.notify(surfaceId: panelId, newState: nil, source: nil) SocketEventBroadcaster.shared.publishAgentState(workspaceId: id, surfaceId: panelId, state: nil, source: nil) + MobileBridgePush.shared.noteAgentStateChanged(workspaceId: id, workspaceTitle: title, changedState: nil) #if DEBUG dlog("surface.agentState.clear workspace=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5))") #endif @@ -229,6 +231,9 @@ extension Workspace { AgentStateWaitRegistry.shared.notify(surfaceId: surfaceId, newState: nil, source: nil) SocketEventBroadcaster.shared.publishAgentState(workspaceId: id, surfaceId: surfaceId, state: nil, source: nil) } + if !clearedAgentSurfaceIds.isEmpty { + MobileBridgePush.shared.noteAgentStateChanged(workspaceId: id, workspaceTitle: title, changedState: nil) + } surfaceListeningPorts.removeAll() listeningPorts.removeAll() metadataBlocks.removeAll() diff --git a/ios/ProgramaSpike/.gitignore b/ios/ProgramaSpike/.gitignore index c4419848..d5df7eab 100644 --- a/ios/ProgramaSpike/.gitignore +++ b/ios/ProgramaSpike/.gitignore @@ -1 +1,8 @@ ProgramaSpike.xcodeproj/ +# Generated by `xcodegen generate` from project.yml's `entitlements.properties` -- +# never hand-edit; the source of truth is project.yml. +ProgramaSpike/ProgramaSpike.entitlements +# Likewise generated, from project.yml's `info.properties`. Every Info.plist key +# lives there rather than in INFOPLIST_KEY_* build settings -- see the comment in +# project.yml for why mixing the two styles silently drops keys. +ProgramaSpike/Info.plist diff --git a/ios/ProgramaSpike/ProgramaSpike/AppDelegate.swift b/ios/ProgramaSpike/ProgramaSpike/AppDelegate.swift new file mode 100644 index 00000000..d387c2b2 --- /dev/null +++ b/ios/ProgramaSpike/ProgramaSpike/AppDelegate.swift @@ -0,0 +1,62 @@ +import CloudKit +import UIKit +import UserNotifications + +/// M3: the parts of CloudKit push delivery that must work even before any SwiftUI scene +/// exists -- see `LiveActivityCloudKitBridge`'s doc comment for why the background-wake path +/// cannot depend on `AppStore`. +final class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + // Required for the OS to hold an APNs token at all -- CloudKit registers that token + // with its own servers internally; Programa never sees or stores it directly. + application.registerForRemoteNotifications() + + // Only needed so the subscription's generic `alertBody` can actually show a lock-screen + // line. The `shouldSendContentAvailable` background wake works regardless of this + // authorization -- it is a separate iOS mechanism gated only by `UIBackgroundModes`. + UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { _, error in + if let error { + NSLog("AppDelegate: notification authorization request failed: %@", "\(error)") + } + } + + return true + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + // No-op by design: CloudKit owns device-token registration with its own servers. + // Programa never persists or transmits this token. + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + NSLog("AppDelegate: remote notification registration failed: %@", "\(error)") + } + + /// The ~30-second background-wake budget Apple grants for a `content-available` push. + /// `LiveActivityCloudKitBridge.reconcile()` is one CloudKit record fetch plus (at most) one + /// local `Activity.update()` -- comfortably inside that window. + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + guard CKNotification(fromRemoteNotificationDictionary: userInfo) != nil else { + completionHandler(.noData) + return + } + + Task { + let result = await LiveActivityCloudKitBridge.reconcile() + completionHandler(result) + } + } +} diff --git a/ios/ProgramaSpike/ProgramaSpike/AppStore.swift b/ios/ProgramaSpike/ProgramaSpike/AppStore.swift index 9f698048..2df72e1c 100644 --- a/ios/ProgramaSpike/ProgramaSpike/AppStore.swift +++ b/ios/ProgramaSpike/ProgramaSpike/AppStore.swift @@ -1,4 +1,5 @@ import ActivityKit +import CloudKit import Foundation import Observation import UIKit @@ -47,6 +48,11 @@ final class AppStore { private(set) var lastSyncError: String? private(set) var isConnecting = false + /// M3: this iPhone's iCloud sign-in state, refreshed on init and on every foreground + /// reconciliation. Does **not** detect a mismatched Apple ID between this phone and the + /// paired Mac -- see `CloudKitPush.accountStatus()`'s doc comment. + private(set) var iCloudAccountStatus: CKAccountStatus = .couldNotDetermine + var pairingTicketDraft: String var pairingTokenDraft: String = "" @@ -77,6 +83,8 @@ final class AppStore { // init and read once in deinit, never concurrently, so the unsafe opt-out // is accurate rather than a way to silence the checker. private nonisolated(unsafe) var willTerminateObserver: NSObjectProtocol? + // `nonisolated(unsafe)` for the same reason as `willTerminateObserver` above. + private nonisolated(unsafe) var didBecomeActiveObserver: NSObjectProtocol? init() { let savedTicket = PairingStore.loadTicket() @@ -105,6 +113,22 @@ final class AppStore { if let ticket = currentTicket { Task { [weak self] in await self?.attemptConnect(ticket: ticket, token: nil) } } + + // M3 mandatory foreground reconciliation: silent CloudKit pushes are coalesced, + // throttled, and dropped entirely after a force-quit, so re-reading the record on + // every foreground is the only thing that guarantees the Live Activity (and the + // iCloud status banner) reflect reality rather than whatever pushes happened to land. + Task { [weak self] in await self?.reconcileFromCloudKit() } + didBecomeActiveObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.reconcileFromCloudKit() + } + } } // `deinit` is nonisolated even on a @MainActor type, so it cannot read @@ -112,6 +136,7 @@ final class AppStore { // and handed to a nonisolated static so teardown needs no isolated access. deinit { Self.removeObserver(willTerminateObserver) + Self.removeObserver(didBecomeActiveObserver) } private nonisolated static func removeObserver(_ token: NSObjectProtocol?) { @@ -175,6 +200,26 @@ final class AppStore { workspaces.first(where: { $0.id == workspaceID })?.title ?? "Workspace" } + /// `nil` when iCloud is signed in and everything should work; otherwise a message to show + /// on the pairing screen. See `iCloudAccountStatus`'s doc comment for what this can't catch + /// (a mismatched Apple ID between this phone and the paired Mac). + var iCloudStatusMessage: String? { + switch iCloudAccountStatus { + case .available: + return nil + case .noAccount: + return "Sign in to iCloud on this iPhone (Settings > [your name]) to get notified when an agent needs you while Programa is in the background." + case .restricted: + return "iCloud is restricted on this iPhone, so background notifications won't work." + case .temporarilyUnavailable: + return "iCloud is temporarily unavailable, so background notifications may be delayed." + case .couldNotDetermine: + return "Could not check this iPhone's iCloud status." + @unknown default: + return "Could not check this iPhone's iCloud status." + } + } + // MARK: - Connection plumbing private func attemptConnect(ticket: String, token: String?) async { @@ -292,6 +337,9 @@ final class AppStore { try await connection.subscribe(classes: ["agent_state", "workspace_lifecycle"]) stage = .workspaces recomputeLiveActivity() + // M3: (re)create the CloudKit query subscription once this device is trusted and + // talking to the Mac -- idempotent, so this is cheap on every reconnect. + await CloudKitPush.ensureSubscription() } catch { lastSyncError = "\(error)" } @@ -506,6 +554,43 @@ final class AppStore { await live.update(ActivityContent(state: state, staleDate: staleDate)) } + // MARK: - CloudKit reconciliation (M3) + // + // The bridge-driven path above (`recomputeLiveActivity`, fed by live `agent_state` events) + // is the precise, real-time source of truth while connected. CloudKit is the backstop for + // when it isn't: a silent push wakes the app (`AppDelegate`/`LiveActivityCloudKitBridge`) + // or, failing that, this reconciliation runs on every foreground regardless. Both paths + // write through the same `liveActivity`/`lastPushedActivityState` bookkeeping so the + // bridge-driven coalescing above stays correct afterward. + + /// Called on `AppStore` init and on every `UIApplication.didBecomeActiveNotification`. + /// Refreshes the iCloud status banner and rebuilds Live Activity state from the last + /// summary the Mac wrote, independent of whether the iroh bridge is currently connected. + func reconcileFromCloudKit() async { + iCloudAccountStatus = await CloudKitPush.accountStatus() + guard let summary = await CloudKitPush.fetchSummary() else { return } + applyCloudKitSummary(summary) + } + + private func applyCloudKitSummary(_ summary: CloudKitPush.Summary) { + ensureLiveActivityStarted() + guard let liveActivity else { return } + + let newState = AgentActivityAttributes.ContentState( + blockedCount: summary.blockedCount, + workingCount: summary.workingCount, + headlineWorkspace: summary.blockedCount > 0 ? summary.mostRecentBlockedWorkspaceTitle : nil + ) + guard newState != lastPushedActivityState else { return } + // CloudKit reconciliation always wins immediately rather than going through the 2s + // coalescing window -- it only runs at most once per foreground/background-wake, so + // there's no churn to coalesce against. + pendingActivityUpdateTask?.cancel() + pendingActivityUpdateTask = nil + pendingActivityContentState = nil + pushActivityUpdate(newState, activityID: liveActivity.id) + } + private func deriveActivityContentState() -> AgentActivityAttributes.ContentState { let allSurfaces = surfacesByWorkspace.values.flatMap { $0 } let blockedCount = allSurfaces.filter { $0.badge == .blocked }.count diff --git a/ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift b/ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift index 6cc63df1..025691c3 100644 --- a/ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift +++ b/ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift @@ -123,7 +123,15 @@ actor BridgeConnection { do { let newEndpoint = try await Endpoint.bind(options: options) boundEndpoint = newEndpoint - let newConnection = try await newEndpoint.connect(addr: targetAddress, alpn: Self.alpn) + // Bound the dial. A ticket that points at a bridge which no longer + // exists -- a Mac that restarted, or an old pairing -- otherwise + // parks here indefinitely and the UI sits on "Connecting…" forever + // with no error and no way out. `Endpoint.connect` routes through + // `irohConnectWithTaskCancellation`, so unlike the raw stream reads + // it genuinely honours cancellation. + let newConnection = try await withRequestTimeout(seconds: 20) { + try await newEndpoint.connect(addr: targetAddress, alpn: Self.alpn) + } establishedConnection = newConnection try newConnection.setMaxConcurrentBiStreams(count: 1) try newConnection.setMaxConcurrentUniStreams(count: 0) @@ -132,6 +140,11 @@ actor BridgeConnection { if let establishedConnection { try? establishedConnection.close(errorCode: 0, reason: Data()) } + if case BridgeError.timedOut = error { + if let boundEndpoint { try? await boundEndpoint.close() } + setPhase(.failed("could not reach that Mac — is Programa running with Settings ▸ Phone turned on?")) + throw error + } if let boundEndpoint { try? await boundEndpoint.close() } @@ -385,6 +398,20 @@ actor BridgeConnection { emitEvent(name: eventName, line: line) return } + // A frame with no `id` and no `event` is a CONNECTION-level rejection, + // not a reply to anything: the bridge sends `{"ok":false,"error": + // {"code":"not_paired"}}` and hangs up before this client has sent a + // single request. Previously this fell through the `id` guard below and + // was silently dropped, so the app sat on "Connecting…" until an + // unrelated 15s ping timeout fired, then retried forever -- never + // surfacing the one fact that mattered: this device is not paired. + if object["id"] == nil, + let ok = object["ok"] as? Bool, ok == false { + let code = (object["error"] as? [String: Any])?["code"] as? String ?? "rejected" + failAllPending(with: BridgeError.rpc(code: code, message: nil)) + setPhase(.failed(code)) + return + } guard let rawId = object["id"] else { return } let id: Int? if let intId = rawId as? Int { @@ -398,6 +425,14 @@ actor BridgeConnection { continuation.resume(returning: line) } + private func failAllPending(with error: Error) { + let outstanding = pending + pending.removeAll() + for (_, continuation) in outstanding { + continuation.resume(throwing: error) + } + } + private func emitEvent(name: String, line: Data) { let decoder = JSONDecoder() switch name { diff --git a/ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift b/ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift new file mode 100644 index 00000000..8e022350 --- /dev/null +++ b/ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift @@ -0,0 +1,102 @@ +import CloudKit +import Foundation + +/// Reads the small agent-activity summary the Mac's `MobileBridgePush` +/// (`Sources/MobileBridge/MobileBridgePush.swift`) writes to the user's own iCloud private +/// database, and owns the `CKQuerySubscription` that lets Apple wake this app with a push when +/// that record changes. +/// +/// Record shape (container id, record type/name, field names) is duplicated here rather than +/// shared with the Mac target -- separate build graphs, same convention already used for the +/// mobile-bridge ALPN (`BridgeConnection.alpn` vs `MobileBridgeListener`'s +/// `mobileBridgeALPN`). Any change to the Mac's field names must be mirrored here by hand. +enum CloudKitPush { + static let containerIdentifier = "iCloud.com.darkroom.programa" + static let recordType = "AgentStatus" + static let recordName = "agent-status-summary" + static let subscriptionID = "agent-status-subscription" + + /// A `CKQuerySubscription` persists server-side once saved, so re-creating it on every + /// launch would be a wasted round trip. Tracked in `UserDefaults` per Apple's documented + /// "save once" pattern for query subscriptions. + private static let subscriptionSavedDefaultsKey = "cloudKitSubscriptionSaved" + + struct Summary: Sendable, Equatable { + var blockedCount: Int + var workingCount: Int + var mostRecentBlockedWorkspaceTitle: String? + } + + private static let container = CKContainer(identifier: containerIdentifier) + + /// Whether this iPhone is signed into iCloud at all. Does **not** detect a mismatched + /// Apple ID between this phone and the paired Mac -- CloudKit exposes no API for that, so a + /// wrong-account pairing still reports `.available` here and silently receives nothing. + /// The UI must say this explicitly (see `PairConnectView`) rather than imply this check is + /// a full guarantee. + static func accountStatus() async -> CKAccountStatus { + (try? await container.accountStatus()) ?? .couldNotDetermine + } + + /// Idempotent: no-ops once a subscription has been saved (tracked locally). Safe to call + /// on every successful connect/pairing. + static func ensureSubscription() async { + guard !UserDefaults.standard.bool(forKey: subscriptionSavedDefaultsKey) else { return } + + let subscription = CKQuerySubscription( + recordType: recordType, + predicate: NSPredicate(value: true), + subscriptionID: subscriptionID, + options: [.firesOnRecordCreation, .firesOnRecordUpdate] + ) + + let info = CKSubscription.NotificationInfo() + // Generic on purpose -- workspace names must never transit Apple's push payload, only + // the record inside the user's own private database. A visible alertBody (with no + // sound) promotes delivery to the reliable high-priority channel and shows a + // lock-screen line without buzzing. + info.alertBody = String(localized: "cloudKit.push.alertBody", defaultValue: "An agent needs you") + info.soundName = nil + // The same delivery also wakes the app in the background so it can refresh the Live + // Activity locally -- see `AppDelegate.didReceiveRemoteNotification`. + info.shouldSendContentAvailable = true + subscription.notificationInfo = info + + let operation = CKModifySubscriptionsOperation( + subscriptionsToSave: [subscription], + subscriptionIDsToDelete: nil + ) + operation.qualityOfService = .utility + + await withCheckedContinuation { (continuation: CheckedContinuation) in + operation.modifySubscriptionsResultBlock = { result in + switch result { + case .success: + UserDefaults.standard.set(true, forKey: subscriptionSavedDefaultsKey) + case let .failure(error): + NSLog("CloudKitPush: subscription save failed: %@", "\(error)") + } + continuation.resume() + } + container.privateCloudDatabase.add(operation) + } + } + + /// Fetches the current summary record. Returns `nil` if the record doesn't exist yet (the + /// Mac hasn't written anything), the account is unavailable, or the fetch failed -- + /// callers should treat all three identically: no-op, keep whatever local state exists. + static func fetchSummary() async -> Summary? { + let recordID = CKRecord.ID(recordName: recordName) + do { + let record = try await container.privateCloudDatabase.record(for: recordID) + return Summary( + blockedCount: record["blockedCount"] as? Int ?? 0, + workingCount: record["workingCount"] as? Int ?? 0, + mostRecentBlockedWorkspaceTitle: record["mostRecentBlockedWorkspaceTitle"] as? String + ) + } catch { + NSLog("CloudKitPush: fetch failed: %@", "\(error)") + return nil + } + } +} diff --git a/ios/ProgramaSpike/ProgramaSpike/LiveActivityCloudKitBridge.swift b/ios/ProgramaSpike/ProgramaSpike/LiveActivityCloudKitBridge.swift new file mode 100644 index 00000000..b860eb7f --- /dev/null +++ b/ios/ProgramaSpike/ProgramaSpike/LiveActivityCloudKitBridge.swift @@ -0,0 +1,37 @@ +import ActivityKit +import Foundation +import UIKit + +/// Background-wake half of M3's push path. Deliberately independent of `AppStore`: a silent +/// (`content-available`) CloudKit push can launch this app fully in the background before any +/// SwiftUI scene exists, and `AppStore` is only a `@State` owned by `ContentView` -- it may not +/// exist yet when `AppDelegate.didReceiveRemoteNotification` fires. This type re-resolves any +/// already-running Live Activity via `Activity.activities` instead, +/// exactly like `AppStore`'s own `nonisolated static` Live Activity helpers do (see that file's +/// doc comment on why `Activity` must be resolved locally rather than captured across an +/// isolation boundary). +/// +/// Only ever *updates* an existing Live Activity -- never starts a new one. Starting one is +/// `AppStore.ensureLiveActivityStarted()`'s job, which requires a live bridge-connected session +/// this path does not have. +enum LiveActivityCloudKitBridge { + private static let staleInterval: TimeInterval = 5 * 60 + + @discardableResult + static func reconcile() async -> UIBackgroundFetchResult { + guard let summary = await CloudKitPush.fetchSummary() else { return .failed } + guard let activity = Activity.activities.first else { return .noData } + + let newState = AgentActivityAttributes.ContentState( + blockedCount: summary.blockedCount, + workingCount: summary.workingCount, + headlineWorkspace: summary.blockedCount > 0 ? summary.mostRecentBlockedWorkspaceTitle : nil + ) + guard newState != activity.content.state else { return .noData } + + await activity.update( + ActivityContent(state: newState, staleDate: Date().addingTimeInterval(staleInterval)) + ) + return .newData + } +} diff --git a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift index a06428da..816aa92c 100644 --- a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift +++ b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift @@ -48,6 +48,24 @@ struct PairConnectView: View { .font(.footnote) } } + + Section("Notifications") { + if let message = store.iCloudStatusMessage { + Text(message) + .font(.footnote) + .foregroundStyle(.orange) + } else { + Text("iCloud is signed in on this iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + } + // CloudKit has no API to detect this, so the app cannot warn about it + // directly -- it can only ever report "signed in" or "not signed in" on + // this device. A mismatch delivers nothing and raises no error. + Text("This iPhone and your Mac must be signed into the same iCloud account for background notifications to arrive. Programa can't detect a mismatch — check the Apple ID on both devices if notifications never show up.") + .font(.footnote) + .foregroundStyle(.secondary) + } } .navigationTitle("Connect to Programa") } diff --git a/ios/ProgramaSpike/ProgramaSpike/PrivacyInfo.xcprivacy b/ios/ProgramaSpike/ProgramaSpike/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..ba404ff2 --- /dev/null +++ b/ios/ProgramaSpike/ProgramaSpike/PrivacyInfo.xcprivacy @@ -0,0 +1,34 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/ios/ProgramaSpike/ProgramaSpike/ProgramaSpikeApp.swift b/ios/ProgramaSpike/ProgramaSpike/ProgramaSpikeApp.swift index 7cfdb94e..0b291f7e 100644 --- a/ios/ProgramaSpike/ProgramaSpike/ProgramaSpikeApp.swift +++ b/ios/ProgramaSpike/ProgramaSpike/ProgramaSpikeApp.swift @@ -2,6 +2,11 @@ import SwiftUI @main struct ProgramaSpikeApp: App { + // M3: registers for remote notifications and handles the background-wake half of the + // CloudKit push path (`didReceiveRemoteNotification`) -- see `AppDelegate`'s doc comment + // for why that logic lives outside the SwiftUI `AppStore`. + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + var body: some Scene { WindowGroup { ContentView() diff --git a/ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist b/ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist index 1617bbde..8b1aab53 100644 --- a/ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist +++ b/ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Programa Spike Widgets + Programa Widgets CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/ios/ProgramaSpike/project.yml b/ios/ProgramaSpike/project.yml index a663b5f8..e026663d 100644 --- a/ios/ProgramaSpike/project.yml +++ b/ios/ProgramaSpike/project.yml @@ -28,19 +28,70 @@ targets: CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "6.0" TARGETED_DEVICE_FAMILY: "1,2" - GENERATE_INFOPLIST_FILE: YES - INFOPLIST_KEY_UILaunchScreen_Generation: YES - INFOPLIST_KEY_CFBundleDisplayName: "Programa Spike" # Black-and-white treatment of the macOS Programa mark. ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + # Every Info.plist key is declared here, explicitly, rather than via + # INFOPLIST_KEY_* build settings. Two reasons, both learned the hard way: + # + # 1. Xcode synthesises only a fixed allow-list of INFOPLIST_KEY_* names. + # UIBackgroundModes is NOT on it: the setting is accepted silently and the + # key never reaches the built plist. Verified on device -- the key was + # absent from ProgramaSpike.app/Info.plist and iOS logged "you still need + # to add "remote-notification" to the list of your supported + # UIBackgroundModes". Same trap as INFOPLIST_KEY_NSExtensionPointIdentifier. + # 2. Declaring `info.path` sets INFOPLIST_FILE, which turns the INFOPLIST_KEY_* + # synthesis off entirely -- so mixing the two styles silently DROPS every + # key still expressed the old way. That is how NSLocalNetworkUsageDescription + # briefly went missing here, which does not fail the build; it just makes + # iOS deny local-network access and forces every connection onto a relay. + # + # Both failure modes are silent. After changing anything below, verify against + # the built app, not the source: + # plutil -extract UIBackgroundModes json -o - /Info.plist + info: + path: ProgramaSpike/Info.plist + properties: + CFBundleDisplayName: "Programa" + UILaunchScreen: {} + # Export-compliance declaration required by App Store Connect. The app + # uses only standard QUIC/TLS (via iroh) and Apple's own CloudKit -- + # no proprietary or non-standard cryptography -- which is the exempt + # case. This is a legal declaration by the publisher: confirm it before + # the first upload rather than treating it as a build setting. + ITSAppUsesNonExemptEncryption: false # Without this, iOS silently denies the app any local-network access # and iroh cannot reach the Mac's LAN address from the pairing ticket, # so the connection falls back to a relay even on the same Wi-Fi. # Measured: phone reported `relay` until this key was added. - INFOPLIST_KEY_NSLocalNetworkUsageDescription: "Programa connects directly to your Mac on the same network instead of routing through a relay." + NSLocalNetworkUsageDescription: "Programa connects directly to your Mac on the same network instead of routing through a relay." # Required for ActivityKit -- without this the app can request a Live # Activity but the system silently refuses to start it. - INFOPLIST_KEY_NSSupportsLiveActivities: YES + NSSupportsLiveActivities: true + # M3: lets a silent (content-available) CloudKit push wake the app in the + # background to refresh the Live Activity. + UIBackgroundModes: + - remote-notification + entitlements: + path: ProgramaSpike/ProgramaSpike.entitlements + properties: + # Required for registerForRemoteNotifications() to succeed at all, which + # CloudKit needs before it can deliver a CKQuerySubscription push. Without + # it the app launches fine and fails at runtime with NSCocoaErrorDomain + # 3000 "no valid aps-environment entitlement string found for + # application", and no push ever arrives. Also requires Push Notifications + # enabled on App ID com.darkroom.programa.spike in the Developer portal. + # Xcode rewrites this to "production" when signing for distribution. + aps-environment: development + com.apple.developer.icloud-container-identifiers: + # Shared with the macOS app so the Mac can write and the phone can + # read the same records. Requires a one-time Developer portal step: + # the container must exist and iCloud must be enabled on BOTH App IDs + # (com.darkroom.programa and com.darkroom.programa.spike). Xcode's + # automatic signing cannot create a container whose name does not + # match the bundle id, so `-allowProvisioningUpdates` will not do it. + - iCloud.com.darkroom.programa + com.apple.developer.icloud-services: + - CloudKit ProgramaSpikeWidgets: type: app-extension @@ -64,6 +115,6 @@ targets: info: path: ProgramaSpikeWidgets/Info.plist properties: - CFBundleDisplayName: Programa Spike Widgets + CFBundleDisplayName: Programa Widgets NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension diff --git a/plans/golden-tumbling-gray.md b/plans/golden-tumbling-gray.md index b014dfbc..993bb411 100644 --- a/plans/golden-tumbling-gray.md +++ b/plans/golden-tumbling-gray.md @@ -105,6 +105,41 @@ Three screens. Anything beyond this is out of scope for v1. Explicitly deferred: live terminal mode, file browser, source control / diff review, browser session view, workspace creation. +### Tappable answers instead of a free-text box (scoped 2026-07-28, not built) + +Requested after using the app: when an agent is blocked, the phone should show the agent's +actual question and its choices as buttons, rather than only offering a text field. Answering +"1" to a question you cannot see is the current experience. + +**The two blocked cases are not equally ready, and that is the whole finding.** + +*AskUserQuestion — the options already exist, structured.* `describeAskUserQuestion` +(`CLI/CLI+Hooks.swift:725-748`) already reads `tool_input.questions[].question` and +`options[].label` out of the `PreToolUse` payload. It then **flattens them into one display +string** (`"[Label A] [Label B]"`, line 742) and stores that as `lastBody` in the session +store. So the structure is captured and immediately thrown away. Making these tappable is +plumbing, not new capture: keep the array, carry it through the session store, expose it on +the v2 surface/agent-state payload, render buttons. + +*Permission prompts — no structured options exist.* This is the far more common blocked case, +and `summarizeClaudeHookNotification` (`CLI/CLI+Hooks.swift:1225-1250`) only ever sees free +text: it scrapes `message`/`body`/`text`/`prompt` and truncates to 180 chars. Claude Code does +not hand the hook a choice list here. The realistic move is **not** to parse the prompt text +but to model the fixed, known set of permission answers as actions, and accept that the +button labels are ours rather than the agent's. + +**Sending the answer back is the risky half.** The bridge allow-list already carries +`surface.send_key` and `surface.send_text`, so a tap becomes a key sequence into the TUI. +That is fire-and-forget into a live terminal: if the prompt has already been answered at the +desk, or the agent moved on, those keystrokes land somewhere arbitrary — potentially +selecting an unrelated menu item. Any implementation needs a staleness guard: carry an +identifier for the exact prompt the buttons were rendered from, and have the Mac reject the +tap if the surface's pending prompt is no longer that one. Without it this feature can +silently take destructive actions, which is strictly worse than the text box it replaces. + +**Order of work:** AskUserQuestion first (structure already exists, low risk), the staleness +guard second, permission prompts last. Do not ship any of it before the guard. + --- ## Layer strategy @@ -383,9 +418,163 @@ Pairing flow, workspace list with state badges from `surface.list` + live `subsc detail with `agent.prompt` entry, Live Activity updating while connected. *Touches:* new iOS app target, new small Swift package consuming only the vendored transport. -**M3 — Remote push. 1–2 weeks, gated on the decision above.** -Device-token registration, Mac-side trigger wired to the existing `blocked` transition (reuse -the classifier — no new detection), NSE with encrypted payload. +**M3 — Remote push via CloudKit. 1–2 weeks.** Decided 2026-07-28 after research. + +**Why CloudKit, and why not the obvious alternatives.** APNs tokens are bound to the app's +bundle ID and Team ID, so there is no per-user key: any provider pushing to our companion must +hold *our* `.p8`. Programa is publicly distributed, so shipping that key makes it extractable. +CloudKit escapes this entirely — each user's Mac writes to *their own* iCloud private database, +their own phone is subscribed, Apple delivers. No key distributed, no infrastructure we run, +per-user isolation by construction. + +Research findings that settled it: + +- **Happy Coder** claims "encrypted, we can't see the content" but + `packages/happy-server/sources/app/push/pushSend.ts` POSTs **plaintext** title/body to Expo's + public API. Expo holds the APNs key — including for self-hosters, since the push token is + minted by Expo. Their E2E encryption covers message content, not notifications. +- **Home Assistant** — the closest analogue (thousands of self-hosted servers, one public iOS + app) — routes through a **centrally-operated relay**. `homeassistant/components/mobile_app/ + notify.py` POSTs plaintext title/body to a `push_url`; HA core holds no APNs key. Free, + 500/day/target, not gated behind Nabu Casa. Notably `push_notification.py` tries a *local* + channel first and only falls back to cloud push after ~10s — the same p2p-first shape we have. +- **Orca** does **no real push**: `mobile/src/notifications/` uses + `scheduleNotificationAsync(trigger: nil)` — local only, while a live RPC connection exists. + Their "no cloud relay" claim is true because a backgrounded phone is never woken. + +**Nobody solves this without a relay or without giving up backgrounded wake.** CloudKit is the +only found path that gets both. + +**Design:** + +1. One `CKRecord` in the user's **private** database holding current blocked-agent summaries. +2. `CKQuerySubscription` created by the iOS app at pairing, with **both** + `alertBody` (no `soundName`) **and** `shouldSendContentAvailable = true`. The alert promotes + the push to the reliable high-priority channel and shows a lock-screen line without buzzing; + the same delivery wakes the app to refresh its Live Activity locally. +3. **Alert text stays generic** ("An agent needs you"). Workspace names live only in the record, + inside the user's own private DB — so the payload transiting Apple carries nothing + meaningful. Stronger than Happy, which sends full plaintext to a third party. +4. **Foreground reconciliation is mandatory.** Silent pushes are coalesced to the latest, + throttled ("two or three per hour"), and **discarded entirely after a force-quit**. The app + must re-read the record and rebuild Live Activity state on every foreground. +5. **Gate on `CKContainer.accountStatus == .available`** and check both devices share an Apple + ID during pairing. Mismatched accounts deliver nothing and raise **no error** — a silent + failure mode. + +**Live Activities are demoted, not deleted.** The notification is the contract; the Live +Activity is a bonus that refreshes when the silent push gets through. It is iOS-only and its +freshness rides the least reliable channel Apple offers, so it gets no further investment. + +### Provisioning-profile groundwork done now, entitlement flip still gated + +The release pipeline can embed a Developer ID provisioning profile +(`scripts/sign-release-app.sh` copies `$PROGRAMA_PROVISION_PROFILE` to +`Contents/embedded.provisionprofile` before the app is signed, wired through +`.github/workflows/release.yml` via an optional `APPLE_PROVISION_PROFILE_BASE64` secret, +verified with `scripts/verify-provision-profile.sh`). None of this enables CloudKit by +itself — `programa.entitlements` still carries no iCloud keys, and +`MobileBridgePush.releaseProvisioningComplete` stays `false` until the steps below are done. +**Steps 1 and 2 were completed on 2026-07-28.** What exists at Apple now: + +- iCloud container `iCloud.com.darkroom.programa` — Active. +- App ID `Programa` / `com.darkroom.programa` — registered with the iCloud capability + (CloudKit) and the container attached. It did **not** exist before: the Mac app is + Developer-ID-signed with no provisioning, so nothing had ever needed one. +- Provisioning profile `Programa Developer ID CloudKit` — Developer ID Application, platform + `OSX`, `ProvisionsAllDevices: true`, expires 2044-07-23. Verified to carry + `com.apple.application-identifier = ZNHHMX2RP6.com.darkroom.programa`, + `com.apple.developer.icloud-services = *`, and + `com.apple.developer.icloud-container-identifiers = [iCloud.com.darkroom.programa]`. + Stored as the `APPLE_PROVISION_PROFILE_BASE64` GitHub secret. +- iOS App ID `com.darkroom.programa.spike` already had the same container attached, so it + needed no change — but its profiles were minted *before* the attachment and carry no + containers. Xcode regenerates them on the next companion build; verify before assuming + the phone can subscribe. + +Two traps worth recording, both of which cost time here: + +- **Xcode's Signing & Capabilities editor cannot finish this.** Setting a team on the + `GhosttyTabs` target makes Xcode attempt an automatic *Development* profile, which fails + with "Device … isn't registered in your developer account". That error is a dead end, not a + blocker to solve: Developer ID profiles set `ProvisionsAllDevices`, so no device + registration is involved. The editor also rewrites ~2,700 lines of `project.pbxproj`, adds + `CODE_SIGN_ENTITLEMENTS`, and reformats every shared scheme — all of which conflicts with + this project's post-build `codesign --entitlements` approach and must be reverted. +- **Registering the App ID must happen before the profile.** The profile wizard only lists + existing App IDs, and `com.darkroom.programa` was not among them. + +**Still required, in order:** + +3. **Add the entitlement** to `programa.entitlements` + (`com.apple.developer.icloud-services`, `com.apple.developer.icloud-container-identifiers`) + in the same change that flips `MobileBridgePush.releaseProvisioningComplete` to `true` — + not before. Confirm `scripts/verify-provision-profile.sh` reports the profile as present, + unexpired, and granting exactly those entitlements. +4. **Mandatory launch-test of the *notarized* build before shipping to `main`.** Notarization + only checks the code signature and scans for malware — it does **not** evaluate whether a + restricted entitlement matches an embedded profile. AMFI does that check at launch time, + on-device, every time. A profile/entitlement mismatch signs cleanly, notarizes cleanly, + staples cleanly, and then the app is silently killed the moment it launches (POSIX 163 — + this project has hit exactly this failure before, see + `restricted-entitlements-brick-app` memory). So: download the actual notarized, + stapled `.dmg` from a **dry-run** workflow_dispatch run (never test straight off `main`'s + auto-ship), install it, and confirm the app actually launches and CloudKit initializes + before that commit is allowed to reach `main`. + +### The schema had to be deployed by hand — Production will not create it for you + +Verified 2026-07-28 in CloudKit Console: container `iCloud.com.darkroom.programa` contains +exactly one record type, `Users`, in **both** the Development and Production environments. +`AgentStatus` does not exist anywhere. That single fact explains the phone's runtime errors: +querying or subscribing to a record type that has never been defined is rejected with +`CKError 15/2000 "Server Rejected Request"`, which reads like an entitlement or auth problem +and is not one. + +Why it will not fix itself once the Mac starts writing: + +- **Development auto-creates record types on first save. Production never does.** Production + schema only ever arrives via *Deploy Schema Changes* from Development. So the Mac's first + write does not bootstrap it. +- **The Mac writes to Production.** Its Developer ID profile pins + `com.apple.developer.icloud-container-environment = Production` (confirmed in the embedded + profile of the notarized dry-run build). So the Mac will hit the same rejection the phone + does, for the same reason. +- **A Debug phone build reads Development.** Even with the schema deployed, a locally-built + companion and a Developer-ID Mac are pointed at two different databases and will never see + each other's records. End-to-end verification needs a Release/TestFlight build of the + companion, or a deliberately dev-signed Mac writer. + +**Resolved 2026-07-28.** `AgentStatus` was created in Development and deployed to Production, +with the fields `MobileBridgePush.performSave` actually writes +(`Sources/MobileBridge/MobileBridgePush.swift:209-221`): + +| field | type | +|---|---| +| `blockedCount` | Int64 | +| `workingCount` | Int64 | +| `mostRecentBlockedWorkspaceTitle` | String | + +The record name is fixed at `agent-status-summary` — a record ID, not a field, so it is not +part of the schema. The `CKQuerySubscription` uses `NSPredicate(value: true)` +(`ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift:46-51`), which requires the type to be +queryable, so a QUERYABLE single-field index on `recordName` was added alongside it. + +Verified in the Production environment after deploying: `AgentStatus`, 9 fields, `recordName` +REFERENCE Queryable, and all three custom fields present. The deployment diff contained only +that record type, that index, and the three default security-role entries CloudKit attaches +to any new type. + +**Still unverified on device:** the phone was disconnected before the subscription could be +re-attempted, so `CKError 15/2000` has not yet been observed to clear. That is the first thing +to check when the companion is next run — and note the environment split above still applies, +so a Debug phone build exercises Development, not the Production schema the Mac will write to. + +**Known limit — this is an iOS-only bet.** CloudKit cannot serve an Android companion. If +Android happens (plausible if Programa reaches Windows), push gets rebuilt around a relay that +fans out to APNs and FCM. The *transport* generalizes fine — `iroh-ffi` is uniffi-based, so +Kotlin bindings are achievable — only push is platform-locked. Accepted deliberately: pay for +the second path when it is real. **M4 — Hardening. 1–2 weeks.** Resync discipline, background-refresh tuning, multi-device pairing, App Store prep. diff --git a/programa.entitlements b/programa.entitlements index 09e191a5..7e367f41 100644 --- a/programa.entitlements +++ b/programa.entitlements @@ -14,5 +14,13 @@ com.apple.security.automation.apple-events + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.icloud-container-identifiers + + iCloud.com.darkroom.programa + diff --git a/scripts/sign-release-app.sh b/scripts/sign-release-app.sh index 389604a6..ba306236 100755 --- a/scripts/sign-release-app.sh +++ b/scripts/sign-release-app.sh @@ -37,5 +37,32 @@ sign_if_present "$app_path/Contents/PlugIns/ProgramaDockTilePlugin.plugin" sign_if_present "$app_path/Contents/Resources/bin/programa" sign_if_present "$app_path/Contents/Resources/bin/ghostty" +# Embed a Developer ID provisioning profile if one was supplied via +# PROGRAMA_PROVISION_PROFILE. This is required for restricted entitlements +# (e.g. CloudKit's com.apple.developer.icloud-services / +# icloud-container-identifiers) to survive AMFI's launch-time check: Apple +# evaluates the profile against the app's entitlements both at install time +# and at every launch, so an app carrying a restricted entitlement but no +# embedded profile signs and notarizes fine and is then killed at launch +# (AMFI, POSIX 163). Xcode's normal provisioning flow never sees this +# because CODE_SIGN_ENTITLEMENTS is empty in the project; entitlements are +# applied here, post-build, directly by codesign. +# +# Ordering is load-bearing: this MUST run before the final `codesign` of the +# app bundle below. The code signature seals Contents/embedded.provisionprofile +# like any other bundle resource, so copying the profile in after signing +# would invalidate the seal and `codesign --verify --deep --strict` (or +# Gatekeeper at install time) would reject the app. Do not move this after +# the app is signed, and do not "simplify" it away — that reintroduces the +# exact AMFI-kill failure mode this comment is warning about. +# +# When PROGRAMA_PROVISION_PROFILE is unset or the file doesn't exist, this is +# a silent no-op: the current no-profile, no-iCloud build keeps working +# exactly as it does today. +if [[ -n "${PROGRAMA_PROVISION_PROFILE:-}" && -f "${PROGRAMA_PROVISION_PROFILE:-}" ]]; then + echo "Embedding provisioning profile from $PROGRAMA_PROVISION_PROFILE" + cp "$PROGRAMA_PROVISION_PROFILE" "$app_path/Contents/embedded.provisionprofile" +fi + "${codesign[@]}" --entitlements "$app_entitlements" "$app_path" /usr/bin/codesign --verify --deep --strict --verbose=2 "$app_path" diff --git a/scripts/verify-provision-profile.sh b/scripts/verify-provision-profile.sh new file mode 100755 index 00000000..c9e92c1c --- /dev/null +++ b/scripts/verify-provision-profile.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Reports on an app bundle's embedded Developer ID provisioning profile, if any. +# +# A missing profile is NOT an error today: Programa currently ships with no +# restricted entitlements (no iCloud), so no profile is expected or required. +# This script exists to verify the profile once one is introduced (see +# plans/golden-tumbling-gray.md M3) — it exits non-zero only when a profile +# IS present but is expired or unreadable, since either of those means the +# app would be killed by AMFI at launch despite passing notarization. + +if [[ "$#" -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +app_path="$1" +profile_path="$app_path/Contents/embedded.provisionprofile" + +if [[ ! -f "$profile_path" ]]; then + echo "No embedded.provisionprofile found at $profile_path." + echo "This is expected for the current build (no restricted entitlements, no profile)." + exit 0 +fi + +plist_xml="$(security cms -D -i "$profile_path" 2>/dev/null)" || { + echo "Embedded provisioning profile is present but could not be decoded (corrupt or unsigned): $profile_path" >&2 + exit 1 +} + +python3 - "$plist_xml" <<'PYEOF' +import datetime +import plistlib +import sys + +xml = sys.argv[1].encode("utf-8") +try: + profile = plistlib.loads(xml) +except Exception as exc: + print(f"Embedded provisioning profile is present but unreadable: {exc}", file=sys.stderr) + sys.exit(1) + +name = profile.get("Name", "") +expiry = profile.get("ExpirationDate") +entitlements = profile.get("Entitlements", {}) + +print(f"Profile name: {name}") +print(f"Expiration: {expiry}") + +if isinstance(expiry, datetime.datetime): + now = datetime.datetime.now(expiry.tzinfo) if expiry.tzinfo else datetime.datetime.utcnow() + if expiry < now: + print("Provisioning profile is EXPIRED.", file=sys.stderr) + sys.exit(1) +else: + print("Warning: could not read ExpirationDate from profile; skipping expiry check.", file=sys.stderr) + +print("Entitlements granted by this profile:") +if entitlements: + for key, value in entitlements.items(): + print(f" {key} = {value}") +else: + print(" (none found)") + +print(f"Provisioning profile verified: {name}") +PYEOF