From 2671e9a1c730e10559716d71464f3480e94c5e03 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:08:51 -0700 Subject: [PATCH 01/41] Create TestFlight Versions Nucleic-Session: 1E7DF522-F57E-4717-8A39-4AA9781F3DBB Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Info.plist | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist index 2fa3083..0870e52 100644 --- a/NucleicRemote/NucleicRemote/Info.plist +++ b/NucleicRemote/NucleicRemote/Info.plist @@ -10,5 +10,12 @@ $(NUCLEIC_CHANNEL) NSSupportsLiveActivities + + ITSAppUsesNonExemptEncryption + From 2d74ed1d78c89b13b9928f5544f401c007201bf4 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:18:00 -0700 Subject: [PATCH 02/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 094ed7e..4a5536e 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -145,9 +145,18 @@ final class RemoteStore: ObservableObject { private var seenSeq: Set = [] private var reconnectAttempts = 0 - /// Offline UI-preview mode (set `NUCLEIC_DEMO=1`): seeds mock state so the surfaces render - /// without a paired Mac. No effect on real use. - private let demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1" + /// Offline demo mode: seeds mock state and simulates the agent locally so every surface + /// renders — and the core loops (send, approve, start chat, to-dos) actually respond — + /// without a paired Mac. Reachable in two ways: the `NUCLEIC_DEMO=1` env var (dev / + /// screenshots, forced on at launch) and a persisted in-app toggle + /// (`enterDemo()` / `exitDemo()`) so an App Store reviewer with no Mac can exercise the app + /// (App Review Guideline 2.1). `@Published` so the UI can show a DEMO indicator and the + /// "Leave demo" affordance. + private static let demoModeKey = "nucleic.demoMode" + @Published private(set) var demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1" + || UserDefaults.standard.bool(forKey: RemoteStore.demoModeKey) + /// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`. + private var demoTasks: [Task] = [] var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil } var deviceFingerprint: String { identity.fingerprint } From 976bb0e066f9fbe78c785b73ddb8b621e0e93514 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:18:09 -0700 Subject: [PATCH 03/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 4a5536e..f4b17c7 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -326,6 +326,40 @@ final class RemoteStore: ObservableObject { NotificationRouter.shared.updateBadge(0) } + /// Enter the in-app demo (the "Explore a demo" button): drop any live connection, persist the + /// flag so it survives relaunch (a reviewer may relaunch), and seed the mock world. `isPaired` + /// then returns true, so `RootView` shows the full TabView. + func enterDemo() { + teardown() + demoMode = true + UserDefaults.standard.set(true, forKey: Self.demoModeKey) + reconnectAttempts = 0 + seedDemo() + } + + /// Leave the demo (Settings ▸ Leave demo): cancel any simulated runs, clear the mock world, + /// and return to the real state — reconnect if a Mac is actually paired, else the pairing intro. + func exitDemo() { + demoMode = false + UserDefaults.standard.set(false, forKey: Self.demoModeKey) + demoTasks.forEach { $0.cancel() } + demoTasks.removeAll() + openSessionID = nil + openEvents = [] + openApprovals = [] + openDiff = nil + diffLoading = false + sessions = [] + dashboard = .empty + LiveActivityManager.shared.end() + NotificationRouter.shared.updateBadge(0) + if IdentityStore.loadPairedHost() != nil { + reconnect() + } else { + connectivity = .unpaired + } + } + // MARK: - Intents (UX_IOS §9) func open(_ sessionID: SessionID) { From d6633f6b7fd92093cdfbde8549b8609399ac9d23 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:18:13 -0700 Subject: [PATCH 04/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index f4b17c7..490144d 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -492,6 +492,7 @@ final class RemoteStore: ObservableObject { // MARK: - Plumbing private func send(_ msg: ClientMsg) { + if demoMode { demoHandle(msg); return } guard let client else { return } Task { await client.send(msg) } } From 0a551a37eb5da6b1b070cbb6d27d179cad2b5971 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:18:24 -0700 Subject: [PATCH 05/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 490144d..381fed6 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -414,6 +414,21 @@ final class RemoteStore: ObservableObject { event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))), event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))), ] + // If this session is blocked on a human, surface a real approval card so the + // Allow/Deny loop is exercisable in the demo (the seeded `a1` session). + if sessions.first(where: { $0.sessionID == sessionID })?.status == .awaitingApproval { + openApprovals = [ApprovalRequest( + id: Self.demoApprovalID(for: sessionID), + sessionID: sessionID, toolCallID: "t-appr", toolName: "Bash", + input: ["command": "npm run deploy"], title: "Run npm run deploy", + risk: .execute, createdAt: Date())] + } + } + + /// Deterministic approval id for a demo session's seeded approval, so re-opening the same + /// session doesn't stack duplicate cards. + private static func demoApprovalID(for sessionID: SessionID) -> ApprovalID { + ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)") } func closeOpen() { From 360e359613b5802a4fde5f219527ee7326239ace Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:19:09 -0700 Subject: [PATCH 06/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 381fed6..2fef85f 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -512,6 +512,191 @@ final class RemoteStore: ObservableObject { Task { await client.send(msg) } } + // MARK: - Demo simulator (offline, interactive) + // + // In demo mode there's no host, so writes can't go over the wire. Instead they mutate the + // already-`@Published` projection directly and (for the agent loop) stream a short canned run, + // so a reviewer can send messages, approve actions, start chats, and manage to-dos and see the + // UI respond — the read-only fixtures (`seedDemo`/`seedDemoTranscript`/`demoDiff`) already + // cover the passive surfaces. + + private func demoHandle(_ msg: ClientMsg) { + switch msg { + case .sendInput(let id, let input): + demoRun(id, userText: input.plainText ?? "") + case .startChat(let req): + demoStartChat(req) + case .approvalRespond(let id, let decision): + demoResolveApproval(id, decision) + case .captureTodo(let req): + demoCaptureTodo(req) + case .setTodoStatus(let id, let status): + demoSetTodoStatus(id, status) + case .deleteTodo(let id): + demoMutateTodos { $0.filter { $0.id != id } } + case .dispatchTodo(let id, _): + demoSetTodoStatus(id, .dispatched) + case .renameSession(let id, let title): + demoUpdateSession(id) { $0.demoCopy(title: title) } + case .setFavorite(let id, let favorite): + demoUpdateSession(id) { $0.demoCopy(favorite: favorite) } + case .setArchived(let id, let archived): + demoUpdateSession(id) { $0.demoCopy(archived: archived) } + case .deleteSession(let id): + sessions.removeAll { $0.sessionID == id } + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) + case .discard(let id): + sessions.removeAll { $0.sessionID == id } + NotificationRouter.shared.updateBadge(needsYouCount) + case .interrupt(let id): + demoUpdateSession(id) { $0.demoCopy(status: .interrupted, disposition: .some(nil)) } + case .integrate(let id, _): + demoAppend(id, .note(NoteEvent(text: "nvrsion: Landed to trunk.", icon: "arrow.triangle.branch"))) + demoUpdateSession(id) { $0.demoCopy(status: .finished, disposition: .some(.completed)) } + case .setSessionModel(let id, let model): + demoUpdateSession(id) { $0.demoCopy(model: .some(model)) } + case .setSessionEffort(let id, let effort): + demoUpdateSession(id) { $0.demoCopy(effort: .some(effort)) } + case .setSessionAuto(let id, let auto): + demoUpdateSession(id) { $0.demoCopy(auto: auto) } + case .setSessionAutoShip(let id, let autoShip): + demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) } + case .setSessionShipBranch(let id, let branch): + demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) } + case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe, + .interrupt, .ping, .cancelQueuedMessage, .fetchDiff: + break // passive / already handled by the seeded fixtures + } + } + + /// Append a transcript event to the open session (no-op if it isn't the one on screen). + private func demoAppend(_ sessionID: SessionID, _ kind: AgentEvent.Kind) { + guard sessionID == openSessionID else { return } + let seq = (openEvents.map(\.seq).max() ?? 0) + 1 + let backend = sessions.first { $0.sessionID == sessionID }?.backend ?? .claudeCode + openEvents.append(AgentEvent( + sessionID: sessionID, seq: seq, at: Date(), backend: backend, nativeType: nil, kind: kind)) + } + + /// Replace a session summary in the list, keeping the badge / Live Activity in sync. + private func demoUpdateSession(_ id: SessionID, _ transform: (WireSessionSummary) -> WireSessionSummary) { + guard let i = sessions.firstIndex(where: { $0.sessionID == id }) else { return } + sessions[i] = transform(sessions[i]) + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) + } + + /// Stream a short, believable canned turn for a session: assistant prose, a tool call + + /// result, usage, and a finish — flipping the summary running → awaiting-input. + private func demoRun(_ sessionID: SessionID, userText: String?) { + if let userText, !userText.isEmpty { + demoAppend(sessionID, .userText(TextChunk(messageID: UUID().uuidString, text: userText, isPartial: false))) + } + demoUpdateSession(sessionID) { $0.demoCopy(status: .running, disposition: .some(nil)) } + let task = Task { [weak self] in + let toolID = UUID().uuidString + guard await self?.demoStep(sessionID, delayMs: 600, { + $0.demoAppend(sessionID, .assistantText(TextChunk( + messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false))) + }) == true else { return } + guard await self?.demoStep(sessionID, delayMs: 700, { + $0.demoAppend(sessionID, .toolCallStarted(ToolCall( + toolCallID: toolID, name: "Bash", input: ["command": "npm test"]))) + }) == true else { return } + guard await self?.demoStep(sessionID, delayMs: 900, { + $0.demoAppend(sessionID, .toolResult(ToolResult( + toolCallID: toolID, content: "42 passing\n0 failing", isError: false))) + }) == true else { return } + guard await self?.demoStep(sessionID, delayMs: 700, { + $0.demoAppend(sessionID, .assistantText(TextChunk( + messageID: UUID().uuidString, + text: "All green — tests pass and the change is in place. Anything else?", + isPartial: false))) + $0.demoAppend(sessionID, .usage(Usage( + inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400))) + $0.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))) + }) == true else { return } + await self?.demoUpdateSession(sessionID) { + $0.demoCopy(status: .awaitingInput, disposition: .some(.completed)) + } + } + demoTasks.append(task) + } + + /// One step of a simulated run: sleep, then (if still in demo and not cancelled) apply a + /// mutation on the main actor. Returns false to abort the remaining steps. + private func demoStep(_ sessionID: SessionID, delayMs: UInt64, _ body: @MainActor (RemoteStore) -> Void) async -> Bool { + try? await Task.sleep(for: .milliseconds(Int(delayMs))) + guard !Task.isCancelled, demoMode else { return false } + body(self) + return true + } + + private func demoStartChat(_ req: StartChatRequest) { + let project = dashboard.projects.first { $0.id == req.projectID } + let id = SessionID(rawValue: "demo-\(UUID().uuidString.prefix(8))") + let title = String(req.message.prefix(48)) + let summary = WireSessionSummary( + sessionID: id, projectID: req.projectID.rawValue, + projectName: project?.name ?? "project", backend: BackendID.forModel(req.model) ?? .claudeCode, + status: .running, disposition: nil, title: title.isEmpty ? "New chat" : title, + branch: "nucleic/\(id.rawValue)", lastSeq: 0, diffStat: nil, + pendingApprovalCount: 0, favorite: false, archived: false, + model: req.model, effort: req.effort, auto: req.auto ?? false, + updatedAt: Date()) + sessions.insert(summary, at: 0) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) + demoRun(id, userText: req.message) + } + + private func demoResolveApproval(_ id: ApprovalID, _ decision: Decision) { + openApprovals.removeAll { $0.id == id } + NotificationRouter.shared.withdrawApproval(id) + guard let sessionID = openSessionID else { return } + switch decision { + case .deny, .cancelRun: + demoAppend(sessionID, .note(NoteEvent(text: "You denied the command.", icon: "hand.raised"))) + demoUpdateSession(sessionID) { + $0.demoCopy(status: .awaitingInput, disposition: .some(.awaitingInput), pendingApprovalCount: 0) + } + case .allow, .allowAlways: + demoAppend(sessionID, .toolResult(ToolResult( + toolCallID: "t-appr", content: "Deployed successfully.", isError: false))) + demoUpdateSession(sessionID) { $0.demoCopy(pendingApprovalCount: 0) } + demoRun(sessionID, userText: nil) // wrap up the turn after the approved tool runs + } + } + + private func demoCaptureTodo(_ req: CaptureTodoRequest) { + let project = req.projectID.flatMap { pid in dashboard.projects.first { $0.id == pid } } + let todo = WireTodo( + id: .generate(), text: req.text, summary: nil, projectID: req.projectID, + projectName: project?.name, status: .open, dispatchedSessionID: nil, + triage: nil, updatedAt: Date()) + demoMutateTodos { [todo] + $0 } + } + + private func demoSetTodoStatus(_ id: TodoID, _ status: TodoStatus) { + demoMutateTodos { todos in + todos.map { todo in + todo.id == id + ? WireTodo(id: todo.id, text: todo.text, summary: todo.summary, + projectID: todo.projectID, projectName: todo.projectName, + status: status, dispatchedSessionID: todo.dispatchedSessionID, + triage: todo.triage, updatedAt: Date()) + : todo + } + } + } + + /// Rebuild the dashboard with a transformed to-do list (its fields are `let`). + private func demoMutateTodos(_ transform: ([WireTodo]) -> [WireTodo]) { + dashboard = DashboardSnapshot( + counts: dashboard.counts, activity: dashboard.activity, projects: dashboard.projects, + todos: transform(dashboard.todos), usage: dashboard.usage, statusFeeds: dashboard.statusFeeds) + } + private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel { NWFrameChannel(endpoint: endpoint) } From 49ad2ed25f9dbfa2dbb60e9fa983d19b8a55f590 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:19:28 -0700 Subject: [PATCH 07/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 2fef85f..68ad3e3 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -565,7 +565,7 @@ final class RemoteStore: ObservableObject { case .setSessionShipBranch(let id, let branch): demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) } case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe, - .interrupt, .ping, .cancelQueuedMessage, .fetchDiff: + .ping, .cancelQueuedMessage, .fetchDiff: break // passive / already handled by the seeded fixtures } } From 3c2774d140c52d9cf8156999d66fa53af790e6c7 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:19:40 -0700 Subject: [PATCH 08/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 68ad3e3..7d3c90d 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -843,3 +843,41 @@ extension Data { DeviceIdentity.fingerprint(ofStaticKey: self) } } + +extension WireSessionSummary { + /// A copy with selected fields overridden — the demo simulator's only way to "mutate" a + /// summary, whose stored properties are all `let`. Nullable fields use a double optional so a + /// caller can distinguish "keep" (omit) from "set to nil" (`.some(nil)`). `updatedAt` bumps + /// to now unless given. Only the fields the simulator touches are exposed. + func demoCopy( + status: SessionStatus? = nil, + disposition: TurnDisposition?? = nil, + title: String? = nil, + favorite: Bool? = nil, + archived: Bool? = nil, + pendingApprovalCount: Int? = nil, + diffStat: DiffStat?? = nil, + model: String?? = nil, + effort: String?? = nil, + auto: Bool? = nil, + autoShip: Bool? = nil, + shipBranch: String?? = nil, + updatedAt: Date? = nil + ) -> WireSessionSummary { + WireSessionSummary( + sessionID: sessionID, projectID: projectID, projectName: projectName, backend: backend, + status: status ?? self.status, + disposition: disposition ?? self.disposition, + title: title ?? self.title, branch: branch, lastSeq: lastSeq, + diffStat: diffStat ?? self.diffStat, + pendingApprovalCount: pendingApprovalCount ?? self.pendingApprovalCount, + favorite: favorite ?? self.favorite, + archived: archived ?? self.archived, + queuedMessage: queuedMessage, queuedMessages: queuedMessages, + model: model ?? self.model, effort: effort ?? self.effort, + auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip, + shipBranch: shipBranch ?? self.shipBranch, + contextInputTokens: contextInputTokens, + updatedAt: updatedAt ?? Date()) + } +} From 4e1f0ec9dad54c1b0e3e80950219d32e363a9f3d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:20:11 -0700 Subject: [PATCH 09/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 7d3c90d..659adfa 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -588,51 +588,45 @@ final class RemoteStore: ObservableObject { } /// Stream a short, believable canned turn for a session: assistant prose, a tool call + - /// result, usage, and a finish — flipping the summary running → awaiting-input. + /// result, usage, and a finish — flipping the summary running → awaiting-input. Each step + /// re-checks demo mode and cancellation so `exitDemo()` stops it cleanly. Runs entirely on the + /// main actor (the store is `@MainActor`, and a `Task` in an isolated method inherits it). private func demoRun(_ sessionID: SessionID, userText: String?) { if let userText, !userText.isEmpty { demoAppend(sessionID, .userText(TextChunk(messageID: UUID().uuidString, text: userText, isPartial: false))) } demoUpdateSession(sessionID) { $0.demoCopy(status: .running, disposition: .some(nil)) } + let toolID = UUID().uuidString let task = Task { [weak self] in - let toolID = UUID().uuidString - guard await self?.demoStep(sessionID, delayMs: 600, { - $0.demoAppend(sessionID, .assistantText(TextChunk( - messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false))) - }) == true else { return } - guard await self?.demoStep(sessionID, delayMs: 700, { - $0.demoAppend(sessionID, .toolCallStarted(ToolCall( - toolCallID: toolID, name: "Bash", input: ["command": "npm test"]))) - }) == true else { return } - guard await self?.demoStep(sessionID, delayMs: 900, { - $0.demoAppend(sessionID, .toolResult(ToolResult( - toolCallID: toolID, content: "42 passing\n0 failing", isError: false))) - }) == true else { return } - guard await self?.demoStep(sessionID, delayMs: 700, { - $0.demoAppend(sessionID, .assistantText(TextChunk( - messageID: UUID().uuidString, - text: "All green — tests pass and the change is in place. Anything else?", - isPartial: false))) - $0.demoAppend(sessionID, .usage(Usage( - inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400))) - $0.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))) - }) == true else { return } - await self?.demoUpdateSession(sessionID) { + func pause(_ ms: Int) async -> Bool { + try? await Task.sleep(for: .milliseconds(ms)) + return !Task.isCancelled && (self?.demoMode ?? false) + } + guard let self else { return } + guard await pause(600) else { return } + self.demoAppend(sessionID, .assistantText(TextChunk( + messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false))) + guard await pause(700) else { return } + self.demoAppend(sessionID, .toolCallStarted(ToolCall( + toolCallID: toolID, name: "Bash", input: ["command": "npm test"]))) + guard await pause(900) else { return } + self.demoAppend(sessionID, .toolResult(ToolResult( + toolCallID: toolID, content: "42 passing\n0 failing", isError: false))) + guard await pause(700) else { return } + self.demoAppend(sessionID, .assistantText(TextChunk( + messageID: UUID().uuidString, + text: "All green — tests pass and the change is in place. Anything else?", + isPartial: false))) + self.demoAppend(sessionID, .usage(Usage( + inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400))) + self.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))) + self.demoUpdateSession(sessionID) { $0.demoCopy(status: .awaitingInput, disposition: .some(.completed)) } } demoTasks.append(task) } - /// One step of a simulated run: sleep, then (if still in demo and not cancelled) apply a - /// mutation on the main actor. Returns false to abort the remaining steps. - private func demoStep(_ sessionID: SessionID, delayMs: UInt64, _ body: @MainActor (RemoteStore) -> Void) async -> Bool { - try? await Task.sleep(for: .milliseconds(Int(delayMs))) - guard !Task.isCancelled, demoMode else { return false } - body(self) - return true - } - private func demoStartChat(_ req: StartChatRequest) { let project = dashboard.projects.first { $0.id == req.projectID } let id = SessionID(rawValue: "demo-\(UUID().uuidString.prefix(8))") From f1f455b2f2be4e1956d846de528940a62c981be6 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:20:34 -0700 Subject: [PATCH 10/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 659adfa..00802b6 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -598,11 +598,11 @@ final class RemoteStore: ObservableObject { demoUpdateSession(sessionID) { $0.demoCopy(status: .running, disposition: .some(nil)) } let toolID = UUID().uuidString let task = Task { [weak self] in + guard let self else { return } func pause(_ ms: Int) async -> Bool { try? await Task.sleep(for: .milliseconds(ms)) - return !Task.isCancelled && (self?.demoMode ?? false) + return !Task.isCancelled && self.demoMode } - guard let self else { return } guard await pause(600) else { return } self.demoAppend(sessionID, .assistantText(TextChunk( messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false))) From 62d9b3e7685ba1612f7aa2d74f77a22e480656dd Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:20:49 -0700 Subject: [PATCH 11/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- .../NucleicRemote/Views/SettingsView.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index d657298..d5281f8 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -113,6 +113,19 @@ struct PairingIntroView: View { Label("Scan QR code", systemImage: "camera").font(.headline).padding(.horizontal) } .buttonStyle(.borderedProminent) + + VStack(spacing: 6) { + Button { store.enterDemo() } label: { + Label("Explore a demo — no Mac needed", systemImage: "play.circle") + } + .buttonStyle(.bordered) + Text("Sample data and a simulated agent — no Mac or account required.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .padding(.top, 8) } .padding() .sheet(isPresented: $showScanner) { From eb6875a6623b7d885fdd07b4af7220fb05c54a9d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:20:54 -0700 Subject: [PATCH 12/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index d5281f8..04bb3fa 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -14,6 +14,18 @@ struct SettingsView: View { var body: some View { NavigationStack { Form { + if store.demoMode { + Section { + Button(role: .destructive) { store.exitDemo() } label: { + Label("Leave demo", systemImage: "arrow.uturn.left") + } + } header: { + Text("Demo") + } footer: { + Text("You're exploring Nucleic with sample data and a simulated agent. Leave the demo to pair with your Mac.") + } + } + Section { Picker("Theme", selection: $appearanceRaw) { ForEach(AppAppearance.allCases) { appearance in From bc96b9c056e72a16f66f8008396b3e8e0f1ed628 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:21:11 -0700 Subject: [PATCH 13/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- .../NucleicRemote/Views/BuildBanner.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 500e9da..e2b9f79 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -131,3 +131,33 @@ struct BuildBanner: View { } } } + +/// A slim strip shown while the app is in offline demo mode, so sample data is never mistaken +/// for a live connection to a Mac. Sits under the `BuildBanner` in `RootView`. +struct DemoBanner: View { + @EnvironmentObject var store: RemoteStore + + var body: some View { + if store.demoMode { + HStack(spacing: 8) { + Image(systemName: "play.circle.fill").font(.system(size: 14, weight: .bold)) + Text("Demo — sample data, no Mac connected") + .font(.footnote.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 8) + Text("DEMO") + .font(.caption2.weight(.heavy)) + .tracking(0.9) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.white.opacity(0.18), in: .capsule) + } + .foregroundStyle(.white.opacity(0.95)) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .frame(maxWidth: .infinity) + .background(Color(red: 0.36, green: 0.36, blue: 0.42)) + .accessibilityLabel("Demo mode — sample data, not connected to a Mac.") + } + } +} From a5a89d1aa0892107554f6c7f521b413e4769594c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:21:15 -0700 Subject: [PATCH 14/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/NucleicRemoteApp.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index 647d985..7a5fc6f 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -48,6 +48,8 @@ struct RootView: View { VStack(spacing: 0) { // The same non-release warning strip the Mac window carries (dev/canary/beta/rc). BuildBanner() + // A demo-mode strip so sample data is never mistaken for a live Mac connection. + DemoBanner() Group { if store.isPaired { TabView(selection: $tab) { From 7353b8dc68adcd5a06ca7e9a68cd13048c212fbd Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:25:08 -0700 Subject: [PATCH 15/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 00802b6..8a9d9f3 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -601,7 +601,8 @@ final class RemoteStore: ObservableObject { guard let self else { return } func pause(_ ms: Int) async -> Bool { try? await Task.sleep(for: .milliseconds(ms)) - return !Task.isCancelled && self.demoMode + if Task.isCancelled { return false } + return self.demoMode } guard await pause(600) else { return } self.demoAppend(sessionID, .assistantText(TextChunk( From 828be033d74cd259b3f3113591e2bed7a2a74dfb Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:25:40 -0700 Subject: [PATCH 16/41] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 8a9d9f3..40b4dc7 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -599,7 +599,7 @@ final class RemoteStore: ObservableObject { let toolID = UUID().uuidString let task = Task { [weak self] in guard let self else { return } - func pause(_ ms: Int) async -> Bool { + @MainActor func pause(_ ms: Int) async -> Bool { try? await Task.sleep(for: .milliseconds(ms)) if Task.isCancelled { return false } return self.demoMode From 6353da5dc8e765465a42b7f3d082fb006e0e4701 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:33:50 -0700 Subject: [PATCH 17/41] Adjust Stat Card Layout Nucleic-Session: 0112C718-2A09-4547-9C71-C6C8A08BA385 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/HomeView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index acddf44..9dd27f5 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -99,7 +99,10 @@ struct HomeView: View { } private var statCards: some View { - HStack(spacing: 12) { + // Flow the cards into two rows (3 + 2) so each gets ~a third of the width rather than a + // fifth — enough room for labels like "In progress" and full token counts to render cleanly + // instead of wrapping or truncating. + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) { StatCard(value: counts.projects, label: "Projects", icon: "folder") StatCard(value: running.count, label: "In progress", icon: "circle.lefthalf.filled") StatCard(value: counts.activeChats, label: "Active", icon: "bolt") From 397f153b579aed7f65362f9ba520d7b434a2593d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:33:56 -0700 Subject: [PATCH 18/41] Login Button Icon Adjustment Nucleic-Session: AAAE9114-6E8D-4DFF-A876-CA91340A686E Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 04bb3fa..25c2ddd 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -140,6 +140,10 @@ struct PairingIntroView: View { .padding(.top, 8) } .padding() + // Fill the available height so the enclosing VStack reaches full screen and the + // BuildBanner/DemoBanner strips stay pinned at the top. Without this the intro only + // claims its intrinsic height, so the stack gets centered and the banner drifts down. + .frame(maxWidth: .infinity, maxHeight: .infinity) .sheet(isPresented: $showScanner) { PairingScannerView { payload in showScanner = false From 04c9d08f582e6eacc40869326b7b812f797cf415 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:37:20 -0700 Subject: [PATCH 19/41] Adjust Stat Card Layout Nucleic-Session: 0112C718-2A09-4547-9C71-C6C8A08BA385 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/HomeView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 9dd27f5..8cf1d23 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -14,6 +14,9 @@ struct HomeView: View { private var running: [WireSessionSummary] { store.liveSessions.filter { $0.status == .running }.sorted { $0.updatedAt > $1.updatedAt } } + /// Outstanding to-dos — everything not yet done (open + dispatched), matching the count the + /// To-dos tab lists. The Mac surfaces this elsewhere on its home; the phone gets a stat card. + private var openTodoCount: Int { store.dashboard.todos.filter { $0.status != .done }.count } private var activityByDay: [Date: Int] { Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.count) }, uniquingKeysWith: +) From b53864f5d0ac18054b693d129388d8a1117ad2dc Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:37:27 -0700 Subject: [PATCH 20/41] Adjust Stat Card Layout Nucleic-Session: 0112C718-2A09-4547-9C71-C6C8A08BA385 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/HomeView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 8cf1d23..5730535 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -109,6 +109,7 @@ struct HomeView: View { StatCard(value: counts.projects, label: "Projects", icon: "folder") StatCard(value: running.count, label: "In progress", icon: "circle.lefthalf.filled") StatCard(value: counts.activeChats, label: "Active", icon: "bolt") + StatCard(value: openTodoCount, label: "To-dos", icon: "checklist") StatCard(value: counts.messages, label: "Messages", icon: "paperplane") StatCard(values: TokenCount.adaptiveCandidates(counts.tokens), label: "Tokens", icon: "text.word.spacing") } From 62b242da5f09df0b08a25428389d4ff7001e2da8 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:37:34 -0700 Subject: [PATCH 21/41] Adjust Stat Card Layout Nucleic-Session: 0112C718-2A09-4547-9C71-C6C8A08BA385 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/HomeView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 5730535..28f6941 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -102,8 +102,8 @@ struct HomeView: View { } private var statCards: some View { - // Flow the cards into two rows (3 + 2) so each gets ~a third of the width rather than a - // fifth — enough room for labels like "In progress" and full token counts to render cleanly + // Flow the cards into two rows of three so each gets ~a third of the width rather than a + // sixth — enough room for labels like "In progress" and full token counts to render cleanly // instead of wrapping or truncating. LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) { StatCard(value: counts.projects, label: "Projects", icon: "folder") From 2b97633b99066ae41ffebffb126b598435bc3d12 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:08:54 -0700 Subject: [PATCH 22/41] Create TestFlight Versions Nucleic-Session: 1E7DF522-F57E-4717-8A39-4AA9781F3DBB Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Info.plist | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist index 0870e52..bb77515 100644 --- a/NucleicRemote/NucleicRemote/Info.plist +++ b/NucleicRemote/NucleicRemote/Info.plist @@ -11,11 +11,11 @@ NSSupportsLiveActivities + ChaCha20-Poly1305 / SHA-256 via CryptoKit). App Store Connect's export-compliance + questionnaire classified this as EXEMPT (standard algorithms / mass-market, no + documentation required), so this is false ("uses no non-exempt encryption"). That + suppresses the per-submission encryption prompt; no compliance code is needed. --> ITSAppUsesNonExemptEncryption - + From f7db53d5e92d417692f4b1847a86c5b9803f186a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:37:42 -0700 Subject: [PATCH 23/41] Update Remote Build Icon Nucleic-Session: E79EC03D-CA77-4899-9DCC-85A053633105 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index e2b9f79..9559865 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -111,7 +111,7 @@ struct BuildBanner: View { case .canary: tint = Color(red: 1.00, green: 0.87, blue: 0.00) // bright canary yellow label = "Canary Build" - icon = "bird.fill" + icon = "bell.and.waves.left.and.right.fill" case .beta: tint = Color(red: 0.13, green: 0.40, blue: 0.86) // blue label = "Engineering Beta" From 566135ae89865cc6901b8807c38669afac06957d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:40:49 -0700 Subject: [PATCH 24/41] Add Remote Release Channel Check Nucleic-Session: 7D1A5F60-CF2F-44E6-B060-51C4C2EA79D1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 9559865..a07e9ee 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -1,4 +1,5 @@ import SwiftUI +import NucleicProtocol /// How this copy of the remote was built — the iOS mirror of the Mac's `BuildChannel`. /// Stamped at archive time via the `NucleicChannel` Info.plist key (`$(NUCLEIC_CHANNEL)`, From d3515605f7345c45b107c2c8bec1c4228259b005 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:40:54 -0700 Subject: [PATCH 25/41] Add Remote Release Channel Check Nucleic-Session: 7D1A5F60-CF2F-44E6-B060-51C4C2EA79D1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index a07e9ee..7acc399 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -17,6 +17,18 @@ enum BuildChannel { case releaseCandidate /// The `stable` channel — no banner. case release + + /// The wire projection sent to the host in `hello`, so it can gate the connection on + /// channel compatibility (`ReleaseChannel.isCompatible`). + var releaseChannel: ReleaseChannel { + switch self { + case .local: .local + case .canary: .canary + case .beta: .beta + case .releaseCandidate: .releaseCandidate + case .release: .release + } + } } /// Build identity for the running app: which channel it came from and its build label. From 51b5820a814237755fbb9c5d34a60749150de26c Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:40:59 -0700 Subject: [PATCH 26/41] Add Remote Release Channel Check Nucleic-Session: 7D1A5F60-CF2F-44E6-B060-51C4C2EA79D1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 40b4dc7..7580520 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -293,7 +293,8 @@ final class RemoteStore: ObservableObject { let client = SyncClient( channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey, mode: .pair(secret: payload.pairingSecret), deviceID: deviceID, - deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex) + deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex, + releaseChannel: BuildInfo.current.channel.releaseChannel) self.client = client consume(client, pairingPayload: payload) } From a06c2c58ad8a95ff5fa1e13e687fe1997eddfee2 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:41:06 -0700 Subject: [PATCH 27/41] Add Remote Release Channel Check Nucleic-Session: 7D1A5F60-CF2F-44E6-B060-51C4C2EA79D1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 7580520..91d286f 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -313,7 +313,8 @@ final class RemoteStore: ObservableObject { let client = SyncClient( channel: channel, identity: identity, hostStaticKey: host.hostStaticKey, mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name, - pushToken: PushRegistrar.shared.tokenHex) + pushToken: PushRegistrar.shared.tokenHex, + releaseChannel: BuildInfo.current.channel.releaseChannel) self.client = client consume(client, pairingPayload: nil) } From d3cc7796dd974f0c19b3ba87d817963809b365bb Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:41:33 -0700 Subject: [PATCH 28/41] Add Remote Release Channel Check Nucleic-Session: 7D1A5F60-CF2F-44E6-B060-51C4C2EA79D1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 91d286f..ead295a 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -783,6 +783,15 @@ final class RemoteStore: ObservableObject { openDiff = diff diffLoading = false case .wireError(let error): + // A channel mismatch is a permanent, connection-fatal condition (the host and this + // remote were built from incompatible release channels). Surface it as a persistent + // failure with a clear message rather than a transient bubble that the follow-on + // `.closed` would paper over with "reconnecting". + if error.code == .channelMismatch { + teardown() + connectivity = .failed(error.message) + break + } // Not fatal — surface as a transient bubble (the Mac's last-error overlay). // Losing an approval race isn't an error worth interrupting for; the card // collapses on the matching `approvalResolved`. From c6175b7185390b9a9f48b36cff238257102e0920 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:42:08 -0700 Subject: [PATCH 29/41] Add Remote Release Channel Check Nucleic-Session: 7D1A5F60-CF2F-44E6-B060-51C4C2EA79D1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index ead295a..329099f 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -783,12 +783,12 @@ final class RemoteStore: ObservableObject { openDiff = diff diffLoading = false case .wireError(let error): - // A channel mismatch is a permanent, connection-fatal condition (the host and this - // remote were built from incompatible release channels). Surface it as a persistent - // failure with a clear message rather than a transient bubble that the follow-on - // `.closed` would paper over with "reconnecting". + // A channel mismatch means the host and this remote were built from incompatible + // release channels. Surface it as a persistent failure with a clear message rather + // than a transient bubble; the follow-on `.closed` still schedules a backoff retry, + // so the connection recovers on its own once either side is updated to a matching + // channel. if error.code == .channelMismatch { - teardown() connectivity = .failed(error.message) break } From 1ec8871536d6cdeb123723c4cdfb424c82c58c94 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:51:55 -0700 Subject: [PATCH 30/41] Build Number Hash Alignment Nucleic-Session: F8E9CC32-59CC-4022-B42C-0B0694C57F42 Co-authored-by: Nucleic --- .../NucleicRemote/Views/BuildBanner.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 7acc399..ddcae59 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -32,11 +32,16 @@ enum BuildChannel { } /// Build identity for the running app: which channel it came from and its build label. -/// The Mac embeds a git commit via a prebuild plugin; the iOS build number is derived from -/// `git rev-list --count` at archive time (scripts/ios-release.sh), so `CFBundleVersion` -/// plays the same role here. +/// Both platforms show the short git commit: the Mac embeds it via a prebuild plugin, and +/// here the NucleicRemote target's "Stamp git commit into Info.plist" build phase writes +/// `git rev-parse --short HEAD` into the `NucleicCommit` Info.plist key. Falls back to +/// `CFBundleVersion` (the `git rev-list --count` build number from scripts/ios-release.sh) +/// when git isn't available. struct BuildInfo { let channel: BuildChannel + /// The short git commit the binary was built from (e.g. "a1b2c3d", "+" suffix when the + /// tree was dirty) — matching the Mac's banner. Falls back to `CFBundleVersion` / "local" + /// if the build phase couldn't resolve a hash. let buildLabel: String /// `CFBundleShortVersionString` when present, else `nil`. let version: String? @@ -47,7 +52,13 @@ struct BuildInfo { init() { let info = Bundle.main.infoDictionary version = info?["CFBundleShortVersionString"] as? String - buildLabel = (info?["CFBundleVersion"] as? String) ?? "local" + // Prefer the stamped git commit (mirrors the Mac's GitCommit.hash); an empty or + // "unknown" value means git wasn't available at build time, so fall back to the + // bundle build number. + let commit = info?["NucleicCommit"] as? String + buildLabel = commit.flatMap { $0.isEmpty || $0 == "unknown" ? nil : $0 } + ?? (info?["CFBundleVersion"] as? String) + ?? "local" channel = Self.detectChannel(stamped: info?["NucleicChannel"] as? String) } From 159d7636a2fde5cb4610be861cf3d0d70d3114c0 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:52:00 -0700 Subject: [PATCH 31/41] Build Number Hash Alignment Nucleic-Session: F8E9CC32-59CC-4022-B42C-0B0694C57F42 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/BuildBanner.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index ddcae59..b062025 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -151,7 +151,7 @@ struct BuildBanner: View { func tooltip(version: String?, build: String) -> String { let v = version.map { "\($0) " } ?? "" - return "\(label) — \(v)build \(build). Not a release build." + return "\(label) — \(v)commit \(build). Not a release build." } } } From 9bc685aa3fe0659a07126661cf1463e1d4689af3 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 21:52:07 -0700 Subject: [PATCH 32/41] Build Number Hash Alignment Nucleic-Session: F8E9CC32-59CC-4022-B42C-0B0694C57F42 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Info.plist | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist index bb77515..6b1a38f 100644 --- a/NucleicRemote/NucleicRemote/Info.plist +++ b/NucleicRemote/NucleicRemote/Info.plist @@ -8,6 +8,11 @@ NucleicChannel $(NUCLEIC_CHANNEL) + + NucleicCommit + NSSupportsLiveActivities