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
+
diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
index 094ed7e..40b4dc7 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 }
@@ -317,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) {
@@ -371,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() {
@@ -449,10 +507,191 @@ 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) }
}
+ // 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,
+ .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. 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
+ guard let self else { return }
+ @MainActor func pause(_ ms: Int) async -> Bool {
+ try? await Task.sleep(for: .milliseconds(ms))
+ if Task.isCancelled { return false }
+ return self.demoMode
+ }
+ 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)
+ }
+
+ 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)
}
@@ -599,3 +838,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())
+ }
+}
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) {
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.")
+ }
+ }
+}
diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift
index acddf44..28f6941 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: +)
@@ -99,10 +102,14 @@ struct HomeView: View {
}
private var statCards: some View {
- HStack(spacing: 12) {
+ // 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")
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")
}
diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift
index d657298..25c2ddd 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
@@ -113,8 +125,25 @@ 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()
+ // 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