From bbafbd279c3971512cb8b2780da9d1b6df585552 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 14:28:40 -0300 Subject: [PATCH 01/14] fix: tell the phone when the Mac does not recognise it The app could reach the Mac, get turned away, and never say so. It sat on "Connecting" with the Connect button greyed out, so there was no way to enter the pairing code that would have fixed it. Two causes. The Mac sends its refusal on its own, not as a reply to anything, and the app only listened for refusals when it had just asked to pair. So on a normal launch the message was thrown away, and the app waited fifteen seconds for an answer that was never coming, then started over. It now recognises that message whenever it arrives, and stops instead of retrying, since being unknown to the Mac does not fix itself. Separately, dialling a Mac that is not there had no time limit, so a saved address for something long gone would hang forever. It now gives up after twenty seconds and says it could not reach that Mac. Confirmed working: a real iPhone paired with the bridge running inside the app and is listed on the Mac by name. --- .../ProgramaSpike/BridgeConnection.swift | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) 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 { From 3a28e2c56c08f1a86f02f1ed0f49cd8d5c02e730 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 15:22:08 -0300 Subject: [PATCH 02/14] plan: decide how the phone gets told while it is asleep Settled how to reach the phone when the app is not open, which is the whole point of the companion and the one thing it still cannot do. Apple ties push credentials to the app, not to the person running it, so there is no way for someone's own Mac to hold its own key for our app. Putting one key inside a public download means anyone can pull it out and send notifications as us. The way out is iCloud. Each person's Mac writes a note to their own private iCloud storage, and their own phone is watching for it. Apple does the delivery. Nothing to hand out, nothing for us to run, and one person's setup cannot touch another's. Looked at how others do it before committing. The closest comparison, Home Assistant, has thousands of self hosted servers and still sends everything through one server they run, in plain text. Another product that advertises no middleman turns out not to wake a sleeping phone at all, which is exactly the problem we are trying to solve. A third claims notifications they cannot read, but the code sends the text in the clear to a third party. So nobody avoids both a middleman and a shared key. The iCloud route does. The lock screen card is demoted to a nice extra. It can only stay current through the least reliable delivery Apple offers, so a plain notification carries the job and the card refreshes when it can. The text says only that an agent needs you; which one stays in the person's own iCloud. Noted as an Apple only bet. An Android companion would need this rebuilt, though the connection itself would carry over. --- plans/golden-tumbling-gray.md | 56 +++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/plans/golden-tumbling-gray.md b/plans/golden-tumbling-gray.md index b014dfbc..73b8579c 100644 --- a/plans/golden-tumbling-gray.md +++ b/plans/golden-tumbling-gray.md @@ -383,9 +383,59 @@ 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. + +**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. From d8c6aa46a7c46cf0b7820ba16333e0f88654549e Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 15:45:14 -0300 Subject: [PATCH 03/14] push: teach the phone to hear from the Mac while it is asleep Adds the plumbing for the phone to be told about a stuck agent when the app is not open, using the person's own iCloud rather than a server we run or a key we hand out. The phone side is done: it watches for a note in the owner's private iCloud, and when one arrives it shows a line on the lock screen without making a sound, and quietly wakes the app to refresh the status card. It also re-reads everything every time you open the app, because these background wakes are throttled, collapsed into one, and dropped entirely if you have swiped the app away. The notification says only that an agent needs you. Which workspace stays in the note in your own iCloud, so nothing meaningful passes through Apple. The Mac side is written but cannot run. Programa is distributed directly rather than through the App Store, and iCloud is one of the permissions Apple only grants through a provisioning profile, which our builds do not carry. Adding it anyway produces an app that signs, notarises, and then refuses to launch. That has happened here before, so it stays switched off behind an explicit flag until the release pipeline can embed a profile. Two things still need a person with the team's Apple account: creating the shared iCloud container and enabling it on both apps. Automatic signing cannot do it, so the phone build for a real device is blocked until then. The simulator build works. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/MobileBridge/MobileBridgePush.swift | 235 ++++++++++++++++++ Sources/Workspace+SidebarTelemetry.swift | 5 + ios/ProgramaSpike/.gitignore | 3 + .../ProgramaSpike/AppDelegate.swift | 62 +++++ .../ProgramaSpike/AppStore.swift | 85 +++++++ .../ProgramaSpike/CloudKitPush.swift | 102 ++++++++ .../LiveActivityCloudKitBridge.swift | 37 +++ .../ProgramaSpike/PairConnectView.swift | 18 ++ .../ProgramaSpike/ProgramaSpikeApp.swift | 5 + ios/ProgramaSpike/project.yml | 17 ++ 11 files changed, 573 insertions(+) create mode 100644 Sources/MobileBridge/MobileBridgePush.swift create mode 100644 ios/ProgramaSpike/ProgramaSpike/AppDelegate.swift create mode 100644 ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift create mode 100644 ios/ProgramaSpike/ProgramaSpike/LiveActivityCloudKitBridge.swift 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..f302dc79 --- /dev/null +++ b/Sources/MobileBridge/MobileBridgePush.swift @@ -0,0 +1,235 @@ +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 -- must stay `false` until the release pipeline can + /// safely ship the CloudKit entitlement.** + /// + /// 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. Flip to `true` only after the + /// release pipeline embeds a provisioning profile carrying the iCloud capability (see this + /// milestone's report for exactly what that requires) and a real Developer-ID-signed, + /// notarized build has been launch-tested outside CI with the entitlement present. + static let releaseProvisioningComplete = false + + 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..2fe382d8 100644 --- a/ios/ProgramaSpike/.gitignore +++ b/ios/ProgramaSpike/.gitignore @@ -1 +1,4 @@ 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 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/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/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/project.yml b/ios/ProgramaSpike/project.yml index a663b5f8..7065c837 100644 --- a/ios/ProgramaSpike/project.yml +++ b/ios/ProgramaSpike/project.yml @@ -41,6 +41,23 @@ targets: # Required for ActivityKit -- without this the app can request a Live # Activity but the system silently refuses to start it. INFOPLIST_KEY_NSSupportsLiveActivities: YES + # M3: lets a silent (content-available) CloudKit push wake the app in the + # background to refresh the Live Activity. Space-separated is Xcode's + # synthesis format for array-valued Info.plist keys via INFOPLIST_KEY_*. + INFOPLIST_KEY_UIBackgroundModes: "remote-notification" + entitlements: + path: ProgramaSpike/ProgramaSpike.entitlements + properties: + 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 From 51870bdfb146989e195313d6174643c2a80f6583 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 16:04:31 -0300 Subject: [PATCH 04/14] ios: get the companion ready to hand to the team Prepares the phone app for TestFlight so people other than me can actually use it. Adds the privacy declaration Apple has required since iOS 17. Without it an upload is rejected outright. Ours is short because the app collects nothing and tracks nobody: it talks to the owner's own Mac and the owner's own iCloud, and the only thing it remembers is which Mac it is paired with. Also declares that the app uses only standard encryption, which is the exempt case and avoids the export paperwork. Worth a second pair of eyes before the first upload, since that one is a legal statement by the publisher rather than a build setting. Both confirmed present in a built app, not just in the project file. An upload-ready build now comes out clean, signed for distribution. --- .../ProgramaSpike/PrivacyInfo.xcprivacy | 34 +++++++++++++++++++ ios/ProgramaSpike/project.yml | 6 ++++ 2 files changed, 40 insertions(+) create mode 100644 ios/ProgramaSpike/ProgramaSpike/PrivacyInfo.xcprivacy 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/project.yml b/ios/ProgramaSpike/project.yml index 7065c837..b9fd4897 100644 --- a/ios/ProgramaSpike/project.yml +++ b/ios/ProgramaSpike/project.yml @@ -31,6 +31,12 @@ targets: GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_CFBundleDisplayName: "Programa Spike" + # 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. + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: false # Black-and-white treatment of the macOS Programa mark. ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Without this, iOS silently denies the app any local-network access From 6b6f710a276fb3b5bf0ea977ab26d77a3fc5bb22 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 16:10:19 -0300 Subject: [PATCH 05/14] ios: call the companion Programa It installed as "Programa Spike", which was fine while it was a spike and odd on a colleague's home screen. Renames the app and its widget before anyone else installs it, since changing the name after people have it is the annoying way round. Only the displayed name changes. The identifier stays as it is, so the paired phone keeps its pairing. --- ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist | 2 +- ios/ProgramaSpike/project.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 b9fd4897..668db6b9 100644 --- a/ios/ProgramaSpike/project.yml +++ b/ios/ProgramaSpike/project.yml @@ -30,7 +30,7 @@ targets: TARGETED_DEVICE_FAMILY: "1,2" GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_UILaunchScreen_Generation: YES - INFOPLIST_KEY_CFBundleDisplayName: "Programa Spike" + INFOPLIST_KEY_CFBundleDisplayName: "Programa" # 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 @@ -87,6 +87,6 @@ targets: info: path: ProgramaSpikeWidgets/Info.plist properties: - CFBundleDisplayName: Programa Spike Widgets + CFBundleDisplayName: Programa Widgets NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension From 69dc98e567072d4fed6587590f2820aa612efbc7 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 16:48:25 -0300 Subject: [PATCH 06/14] release: be ready to carry a provisioning profile Groundwork so the Mac side can eventually use iCloud without breaking the app on launch. Apple only grants some permissions, iCloud among them, through a profile that ships inside the app and is checked every single time it starts. We have never shipped one, because nothing we used needed it. Add such a permission without the profile and the app signs, passes Apple's checks, and then refuses to open. That has happened here before. Signing can now embed a profile when one is supplied, and the release build passes one through when the secret exists. With no profile and no secret, which is today, everything behaves exactly as before. Confirmed by signing a real build with the release certificate and no profile: it comes out valid and meets its designated requirement, with no profile inside, same as what ships now. Also adds a small checker that reports what a profile grants and when it expires, and says nothing is wrong when there is no profile at all. Still switched off. Turning iCloud on needs a profile created against the team account, a secret added, and, before any of it merges, launching the finished signed build by hand. Apple's own checks will not catch this failure, only opening the app will. --- .github/workflows/release.yml | 20 +++++++++ plans/golden-tumbling-gray.md | 37 ++++++++++++++++ scripts/sign-release-app.sh | 27 ++++++++++++ scripts/verify-provision-profile.sh | 67 +++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100755 scripts/verify-provision-profile.sh 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/plans/golden-tumbling-gray.md b/plans/golden-tumbling-gray.md index 73b8579c..839cf480 100644 --- a/plans/golden-tumbling-gray.md +++ b/plans/golden-tumbling-gray.md @@ -431,6 +431,43 @@ only found path that gets both. 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. +**Still required, in order, before the iCloud entitlement can be added:** + +1. **Apple Developer portal, one-time:** register the iCloud container (e.g. + `iCloud.com.darkroom.programa`), enable the iCloud capability (CloudKit) on the app ID + `com.darkroom.programa`, then generate a **Developer ID with CloudKit** provisioning + profile for that app ID. This is a manual portal action — there is no CLI/API path scripted + here, and it must be redone whenever the container or app ID's capabilities change. +2. **Export and store the profile:** download the `.provisionprofile`, base64-encode it + (`base64 -i profile.provisionprofile | pbcopy`), and add it as the + `APPLE_PROVISION_PROFILE_BASE64` GitHub secret. Treat it like the existing signing + secrets — it is not sensitive in the same way as the `.p12` password, but it is + team/account-specific and should not be committed. +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`. + **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 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 From 532ebec9cdedb7d9ee97cfed8382c764589a438d Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 17:44:09 -0300 Subject: [PATCH 07/14] feat: turn on CloudKit push for the mobile companion Your Mac can now tell your phone an agent is blocked while the app is backgrounded. It writes a summary to your own iCloud private database and Apple delivers the notification -- nothing runs on our side, and no key ships in the app. Adds the iCloud entitlement and flips the MobileBridgePush kill switch in the same commit, because they are only safe together: a restricted entitlement without a matching embedded provisioning profile signs and notarizes cleanly and is then killed at launch by AMFI. Requires the APPLE_PROVISION_PROFILE_BASE64 secret, carrying the "Programa Developer ID CloudKit" profile for app ID com.darkroom.programa with container iCloud.com.darkroom.programa. --- Sources/MobileBridge/MobileBridgePush.swift | 23 ++++++++++++++------- programa.entitlements | 8 +++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Sources/MobileBridge/MobileBridgePush.swift b/Sources/MobileBridge/MobileBridgePush.swift index f302dc79..737b0900 100644 --- a/Sources/MobileBridge/MobileBridgePush.swift +++ b/Sources/MobileBridge/MobileBridgePush.swift @@ -61,8 +61,20 @@ final class MobileBridgePush: @unchecked Sendable { private var coalesceScheduled = false private var didLogAccountUnavailable = false - /// **Hard build-time kill switch -- must stay `false` until the release pipeline can - /// safely ship the CloudKit entitlement.** + /// **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 @@ -79,11 +91,8 @@ final class MobileBridgePush: @unchecked Sendable { /// `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. Flip to `true` only after the - /// release pipeline embeds a provisioning profile carrying the iCloud capability (see this - /// milestone's report for exactly what that requires) and a real Developer-ID-signed, - /// notarized build has been launch-tested outside CI with the entitlement present. - static let releaseProvisioningComplete = false + /// 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 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 + From a3d9a52daaea4d00b94fbb6a89c0680a85e75371 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 17:45:11 -0300 Subject: [PATCH 08/14] docs: record what the CloudKit portal setup actually required --- plans/golden-tumbling-gray.md | 43 +++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/plans/golden-tumbling-gray.md b/plans/golden-tumbling-gray.md index 839cf480..5135a2f5 100644 --- a/plans/golden-tumbling-gray.md +++ b/plans/golden-tumbling-gray.md @@ -440,18 +440,37 @@ The release pipeline can embed a Developer ID provisioning profile 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. -**Still required, in order, before the iCloud entitlement can be added:** - -1. **Apple Developer portal, one-time:** register the iCloud container (e.g. - `iCloud.com.darkroom.programa`), enable the iCloud capability (CloudKit) on the app ID - `com.darkroom.programa`, then generate a **Developer ID with CloudKit** provisioning - profile for that app ID. This is a manual portal action — there is no CLI/API path scripted - here, and it must be redone whenever the container or app ID's capabilities change. -2. **Export and store the profile:** download the `.provisionprofile`, base64-encode it - (`base64 -i profile.provisionprofile | pbcopy`), and add it as the - `APPLE_PROVISION_PROFILE_BASE64` GitHub secret. Treat it like the existing signing - secrets — it is not sensitive in the same way as the `.p12` password, but it is - team/account-specific and should not be committed. +**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` — From 41263c7ac753c390a2abd84d91045e81d0d1fbb1 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 18:05:21 -0300 Subject: [PATCH 09/14] fix: make push actually reach the phone Two separate bugs, both silent, both found by running the app on a real device rather than by building it. The app could never receive a notification: registerForRemoteNotifications() failed with "no valid aps-environment entitlement string found for application", so CloudKit never got an APNs token. Push Notifications is now enabled on App ID com.darkroom.programa.spike and the entitlement is declared. The background wake was also dead: INFOPLIST_KEY_UIBackgroundModes is not one of the names Xcode synthesises into a generated Info.plist, so the key never reached the built app and iOS logged that "remote-notification" was missing. Every Info.plist key now lives in an explicit info.properties block, because setting info.path turns the INFOPLIST_KEY_* synthesis off entirely and mixing the two styles drops whatever is still expressed the old way. Verified on an iPhone 16 Pro: both runtime errors are gone and the rebuilt profile carries aps-environment plus the iCloud container. --- ios/ProgramaSpike/.gitignore | 4 +++ ios/ProgramaSpike/project.yml | 50 +++++++++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/ios/ProgramaSpike/.gitignore b/ios/ProgramaSpike/.gitignore index 2fe382d8..d5df7eab 100644 --- a/ios/ProgramaSpike/.gitignore +++ b/ios/ProgramaSpike/.gitignore @@ -2,3 +2,7 @@ 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/project.yml b/ios/ProgramaSpike/project.yml index 668db6b9..e026663d 100644 --- a/ios/ProgramaSpike/project.yml +++ b/ios/ProgramaSpike/project.yml @@ -28,32 +28,60 @@ 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" + # 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. - INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: false - # Black-and-white treatment of the macOS Programa mark. - ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + 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. Space-separated is Xcode's - # synthesis format for array-valued Info.plist keys via INFOPLIST_KEY_*. - INFOPLIST_KEY_UIBackgroundModes: "remote-notification" + # 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: From e98cc34c6cec534dc65f70f9d7437db1313aa7b8 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 18:11:40 -0300 Subject: [PATCH 10/14] fix: stop the permission badge getting stuck on A tab could sit there saying "Claude needs your permission" while that pane was visibly running commands. Answering the prompt cleared nothing, and it never recovered -- the badge stayed lit until the session ended. The hook that clears it resolved the workspace with `try`. When that threw, the enclosing catch treated it as a teardown error, printed "OK", and returned before any of the three clears ran. Claude Code saw a healthy hook, so nothing retried and nothing complained. The sibling surface lookup right below had already been fixed for exactly this reason; this was the other half of it. Nothing else would have caught it either: agent state has no TTL or watchdog, and the screen-detection fallback deliberately refuses to touch a surface a hook has claimed, so a stale blocked state is unrecoverable by design. Resolution is now best-effort, falling back to the workspace the notification hook recorded when it set the blocked state -- that is the workspace that needs clearing, so it is the right thing to use when a live lookup is not available. Only a total absence of any workspace id returns early now, and that case has nothing to clear anyway. No regression test: CLI sources are not in any test target, so covering this needs a test seam rather than a fake assertion over source text. --- CLI/CLI+Hooks.swift | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) 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. From 955d707ecd966e3fb930d8f9a101e9c6a92409f8 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 18:14:27 -0300 Subject: [PATCH 11/14] docs: scope tappable agent answers on the phone --- plans/golden-tumbling-gray.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/plans/golden-tumbling-gray.md b/plans/golden-tumbling-gray.md index 5135a2f5..7ff68b1a 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 From 0ee28f844079981e095b38c935f69d44205cf0c7 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 18:16:24 -0300 Subject: [PATCH 12/14] docs: record why CloudKit rejects everything (no AgentStatus schema) --- plans/golden-tumbling-gray.md | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/plans/golden-tumbling-gray.md b/plans/golden-tumbling-gray.md index 7ff68b1a..abe8ec14 100644 --- a/plans/golden-tumbling-gray.md +++ b/plans/golden-tumbling-gray.md @@ -522,6 +522,48 @@ Two traps worth recording, both of which cost time here: auto-ship), install it, and confirm the app actually launches and CloudKit initializes before that commit is allowed to reach `main`. +### The schema does not exist yet, and 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. + +**Required, and deliberately left for a human — deploying schema to Production is not +reversible in the way normal config is.** In CloudKit Console, Development environment, +create record type `AgentStatus` 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). The +`CKQuerySubscription` needs the type queryable, so add the queryable index CloudKit prompts +for. Then *Deploy Schema Changes…* to Production. + +Until that is done, no amount of correct entitlement or provisioning makes a notification +arrive. This was invisible before now because `releaseProvisioningComplete` kept the Mac +writer dark, so nothing had ever attempted the first write. + **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 From 447df448f5624ce7f1c3be8e62871b47079070c5 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 28 Jul 2026 18:35:44 -0300 Subject: [PATCH 13/14] docs: AgentStatus schema deployed to production --- plans/golden-tumbling-gray.md | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/plans/golden-tumbling-gray.md b/plans/golden-tumbling-gray.md index abe8ec14..993bb411 100644 --- a/plans/golden-tumbling-gray.md +++ b/plans/golden-tumbling-gray.md @@ -522,7 +522,7 @@ Two traps worth recording, both of which cost time here: auto-ship), install it, and confirm the app actually launches and CloudKit initializes before that commit is allowed to reach `main`. -### The schema does not exist yet, and Production will not create it for you +### 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. @@ -545,10 +545,9 @@ Why it will not fix itself once the Mac starts writing: each other's records. End-to-end verification needs a Release/TestFlight build of the companion, or a deliberately dev-signed Mac writer. -**Required, and deliberately left for a human — deploying schema to Production is not -reversible in the way normal config is.** In CloudKit Console, Development environment, -create record type `AgentStatus` with the fields `MobileBridgePush.performSave` actually -writes (`Sources/MobileBridge/MobileBridgePush.swift:209-221`): +**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 | |---|---| @@ -556,13 +555,20 @@ writes (`Sources/MobileBridge/MobileBridgePush.swift:209-221`): | `workingCount` | Int64 | | `mostRecentBlockedWorkspaceTitle` | String | -The record name is fixed at `agent-status-summary` (a record ID, not a field). The -`CKQuerySubscription` needs the type queryable, so add the queryable index CloudKit prompts -for. Then *Deploy Schema Changes…* to Production. +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. -Until that is done, no amount of correct entitlement or provisioning makes a notification -arrive. This was invisible before now because `releaseProvisioningComplete` kept the Mac -writer dark, so nothing had ever attempted the first write. +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 From 37647b000af4919479b434af5506c8c7c075f882 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 29 Jul 2026 09:16:28 -0300 Subject: [PATCH 14/14] fix: stop the CloudKit push crashing agents on unsigned builds MobileBridgePush built its CloudKit container the moment the singleton was first touched, which happens before any of the on/off checks inside noteAgentStateChanged get a chance to run. On any build that is not signed with the real provisioning profile, asking for that container kills the process outright, so the app died the first time an agent changed state. That is every debug build and every test machine, which is why the agent state tests and the socket tests were failing. The container is now only built at the point something is actually about to be sent, so builds without the profile just do nothing instead of dying. --- Sources/MobileBridge/MobileBridgePush.swift | 34 ++++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/Sources/MobileBridge/MobileBridgePush.swift b/Sources/MobileBridge/MobileBridgePush.swift index 737b0900..5d2b59ee 100644 --- a/Sources/MobileBridge/MobileBridgePush.swift +++ b/Sources/MobileBridge/MobileBridgePush.swift @@ -15,8 +15,10 @@ import Foundation /// - `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. +/// not signed into iCloud on this Mac. Note this gate does *not* cover a missing container +/// entitlement: `CKContainer(identifier:)` traps before `accountStatus` is ever consulted, +/// which is why the container is built lazily behind the guards (see `makeContainer`) and +/// why `releaseProvisioningComplete` must move in lockstep with the entitlement itself. /// /// Trigger point: called directly from `Workspace.updatePanelAgentState` / /// `clearPanelAgentState` / `resetSidebarContext` (`Workspace+SidebarTelemetry.swift`), @@ -44,7 +46,16 @@ final class MobileBridgePush: @unchecked Sendable { var mostRecentBlockedWorkspaceTitle: String? } - private let container: CKContainer + /// Deferred rather than built in `init`: `CKContainer(identifier:)` traps when the running + /// process is not actually entitled for that container, and `shared` is constructed on the + /// first `noteAgentStateChanged` call -- i.e. *before* any of the guards in that method can + /// run. Every ad-hoc-signed build (`codesign --sign -`, which is every Debug and CI test + /// host) is unentitled, so building the container eagerly crashed the host on the first + /// agent-state transition regardless of the kill switch below. Resolved lazily on `queue` + /// at the point of an actual write instead, which is the first moment the container is + /// genuinely needed and the only place it is used. + private let makeContainer: () -> CKContainer + private var resolvedContainer: CKContainer? /// Serializes all mutable state below (`trackedBlockedTitle` through /// `didLogAccountUnavailable`). Every access to that state -- including from CloudKit's @@ -94,8 +105,21 @@ final class MobileBridgePush: @unchecked Sendable { /// 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 + private init( + makeContainer: @escaping () -> CKContainer = { + CKContainer(identifier: MobileBridgePush.containerIdentifier) + } + ) { + self.makeContainer = makeContainer + } + + /// `queue`-confined. Both call sites (`checkAccountStatusAndSave`, `performSave`) already + /// run there, so the memoisation needs no further synchronisation. + private var container: CKContainer { + if let resolvedContainer { return resolvedContainer } + let container = makeContainer() + resolvedContainer = container + return container } /// Call on every agent-state transition (set or clear) that already calls