M4: NucleicRemote iPhone app — SwiftUI client over the shared protocol

A real iOS Xcode app (ios/NucleicRemote) linking the NucleicProtocol SwiftPM
library as a local package. Builds for the iOS 27 simulator and launches to the
pairing screen.

- Transport: NWFrameChannel (NWConnection) + LANDiscovery (Bonjour _nucleic._tcp).
- Engine: drives NucleicProtocol.SyncClient (Noise XXpsk0 pair / IK reconnect,
  hello/welcome, HostMsg→Event stream).
- State: RemoteStore (ObservableObject) — the single on-device projection of host
  state; IdentityStore persists the device identity (Keychain) + pinned host.
- UI (UX_IOS): attention-first SessionsView, SessionDetailView (transcript/diff +
  status-driven action area / composer), ApprovalCardView with Face ID gate on
  high-risk approvals + allow-always menu, PairingScannerView (AVFoundation QR),
  SettingsView, connection chip. Same status glyphs/semantics as the Mac.

Add-iPhone QR display + server start live on the macOS side (follow-up); push /
Live Activity are M5 (needs the relay). gitignore keeps this .xcodeproj despite
the blanket *.xcodeproj rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-06-13 01:12:55 -07:00
co-authored by Claude Opus 4.8
commit 6f24b42004
20 changed files with 1494 additions and 0 deletions
@@ -0,0 +1,113 @@
import SwiftUI
import LocalAuthentication
import NucleicProtocol
/// The defining interaction (UX_IOS §5): answer an approval. High-risk (destructive/network)
/// requires Face ID/Touch ID before Allow is enabled — deliberate friction mirroring the Mac.
/// First-responder-wins: if the Mac (or another phone) answers first, the host's
/// `approvalResolved` removes this card.
struct ApprovalCardView: View {
@EnvironmentObject var store: RemoteStore
let approval: ApprovalRequest
@State private var biometricPassed = false
@State private var authError: String?
@State private var showAlwaysMenu = false
private var requiresBiometric: Bool { approval.risk.isHigh }
private var allowEnabled: Bool {
store.connectivity.isLive && (!requiresBiometric || biometricPassed)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("Permission requested").font(.subheadline.weight(.semibold))
}
HStack(spacing: 6) {
Text(approval.toolName).font(.caption.weight(.bold))
Text(approval.risk.label)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6).padding(.vertical, 2)
.background(approval.risk.color.opacity(0.2), in: Capsule())
.foregroundStyle(approval.risk.color)
}
Text(approval.input.compactSummary)
.font(.caption.monospaced())
.lineLimit(3)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
if requiresBiometric && !biometricPassed {
Button {
authenticate()
} label: {
Label("Face ID required to allow", systemImage: "faceid").font(.caption)
}
.buttonStyle(.bordered)
}
if let authError {
Text(authError).font(.caption2).foregroundStyle(.red)
}
HStack(spacing: 10) {
Button(role: .destructive) {
store.respond(approval, .deny(reason: nil))
} label: {
Text("Deny").frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
store.respond(approval, .allow())
} label: {
Text("Allow").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(!allowEnabled)
}
if !store.capabilities.allowAlwaysScopes.isEmpty {
Menu("Allow always…") {
ForEach(store.capabilities.allowAlwaysScopes, id: \.self) { scope in
Button(alwaysLabel(scope)) {
store.respond(approval, .allowAlways(scope))
}
.disabled(!allowEnabled)
}
}
.font(.caption)
}
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
.onChange(of: approval.id) { _, _ in biometricPassed = false; authError = nil }
}
private func alwaysLabel(_ scope: AlwaysScope) -> String {
switch scope {
case .session: "This command, this session"
case .toolName: "Any \(approval.toolName), this session"
case .toolNameWithPattern: "\(approval.toolName) matching this pattern"
}
}
private func authenticate() {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
authError = "Biometrics unavailable"; return
}
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Approve a \(approval.risk.label) action"
) { success, evalError in
Task { @MainActor in
if success { biometricPassed = true; authError = nil }
else { authError = evalError?.localizedDescription ?? "Authentication failed" }
}
}
}
}
@@ -0,0 +1,25 @@
import SwiftUI
/// Persistent connectivity indicator (UX_IOS §6). Connectivity is unambiguous: green LAN
/// when live, amber while reconnecting, gray when the Mac is unreachable.
struct ConnectionChip: View {
@EnvironmentObject var store: RemoteStore
var body: some View {
HStack(spacing: 6) {
Circle().fill(color).frame(width: 8, height: 8)
Text(store.connectivity.label).font(.footnote.weight(.medium))
}
.padding(.horizontal, 12).padding(.vertical, 6)
.background(.ultraThinMaterial, in: Capsule())
}
private var color: Color {
switch store.connectivity {
case .connected: .green
case .connecting, .reconnecting: .orange
case .hostOffline, .unpaired: .gray
case .failed: .red
}
}
}
@@ -0,0 +1,102 @@
import SwiftUI
import AVFoundation
import NucleicProtocol
/// Camera QR scanner (UX_IOS §7). Decodes the Mac's `nucleic://pair?d=…` QR into a
/// `PairingPayload` and hands it back exactly once.
struct PairingScannerView: View {
let onScanned: (PairingPayload) -> Void
@Environment(\.dismiss) private var dismiss
@State private var error: String?
var body: some View {
NavigationStack {
ZStack {
QRScannerRepresentable { value in handle(value) }
.ignoresSafeArea()
VStack {
Spacer()
RoundedRectangle(cornerRadius: 16)
.stroke(.white.opacity(0.8), lineWidth: 3)
.frame(width: 240, height: 240)
Spacer()
if let error {
Text(error).foregroundStyle(.white)
.padding().background(.red.opacity(0.8), in: Capsule())
} else {
Text("Point at the QR code on your Mac")
.foregroundStyle(.white)
.padding().background(.black.opacity(0.5), in: Capsule())
}
Spacer().frame(height: 40)
}
}
.navigationTitle("Scan to pair")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
}
}
private func handle(_ value: String) {
guard let payload = try? PairingPayload(qrString: value) else {
error = "That isn't a Nucleic pairing code"; return
}
onScanned(payload)
}
}
/// UIKit bridge for the AVCaptureSession metadata scan.
struct QRScannerRepresentable: UIViewControllerRepresentable {
let onFound: (String) -> Void
func makeUIViewController(context: Context) -> QRScannerController {
let controller = QRScannerController()
controller.onFound = onFound
return controller
}
func updateUIViewController(_ controller: QRScannerController, context: Context) {}
}
final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onFound: ((String) -> Void)?
private let session = AVCaptureSession()
private var didFind = false
override func viewDidLoad() {
super.viewDidLoad()
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { return }
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return }
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let preview = AVCaptureVideoPreviewLayer(session: session)
preview.videoGravity = .resizeAspectFill
preview.frame = view.layer.bounds
view.layer.addSublayer(preview)
Task.detached { [session] in session.startRunning() }
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
session.stopRunning()
}
func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
guard !didFind,
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let value = object.stringValue else { return }
didFind = true
onFound?(value)
}
}
@@ -0,0 +1,115 @@
import SwiftUI
import NucleicProtocol
/// One session: a segmented Transcript/Diff read view over a status-driven action area
/// (UX_IOS §4). Subscribes on appear, unsubscribes on disappear.
struct SessionDetailView: View {
@EnvironmentObject var store: RemoteStore
let sessionID: SessionID
@State private var tab = 0
@State private var draft = ""
private var summary: WireSessionSummary? {
store.sessions.first { $0.sessionID == sessionID }
}
var body: some View {
VStack(spacing: 0) {
Picker("", selection: $tab) {
Text("Transcript").tag(0)
Text("Diff").tag(1)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 8)
Divider()
if tab == 0 {
TranscriptList(events: store.openEvents)
} else {
DiffSummaryView(diffStat: summary?.diffStat)
}
actionArea
}
.navigationTitle(summary?.title ?? "Session")
.navigationBarTitleDisplayMode(.inline)
.onAppear { store.open(sessionID) }
.onDisappear { store.closeOpen() }
}
@ViewBuilder
private var actionArea: some View {
Divider()
if let approval = store.openApprovals.first {
ApprovalCardView(approval: approval)
.padding()
} else if let status = summary?.status, status == .awaitingInput || status.isTerminal {
HStack(spacing: 8) {
TextField("Send a follow-up…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
Button {
store.sendInput(draft, to: sessionID)
draft = ""
} label: {
Image(systemName: "arrow.up.circle.fill").font(.title2)
}
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
}
.padding()
} else if summary?.status == .running {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Agent is working…").font(.footnote).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
}
struct TranscriptList: View {
let events: [AgentEvent]
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(events, id: \.seq) { event in
TranscriptRow(event: event).id(event.seq)
}
}
.padding()
}
.onChange(of: events.count) {
if let last = events.last { withAnimation { proxy.scrollTo(last.seq, anchor: .bottom) } }
}
}
}
}
struct DiffSummaryView: View {
let diffStat: DiffStat?
var body: some View {
VStack(spacing: 12) {
if let diff = diffStat, diff.filesChanged > 0 {
Label("\(diff.filesChanged) file\(diff.filesChanged == 1 ? "" : "s") changed", systemImage: "doc.text")
.font(.headline)
HStack(spacing: 16) {
Text("+\(diff.added)").foregroundStyle(.green)
Text("−\(diff.removed)").foregroundStyle(.red)
}
.font(.title3.monospacedDigit())
Text("Open the Mac for the full patch.")
.font(.footnote).foregroundStyle(.secondary)
} else {
ContentUnavailableView("No changes yet", systemImage: "doc")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
@@ -0,0 +1,79 @@
import SwiftUI
import NucleicProtocol
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE, with a
/// persistent connection chip. Tapping a row pushes detail.
struct SessionsView: View {
@EnvironmentObject var store: RemoteStore
private var grouped: [(title: String, rows: [WireSessionSummary])] {
let sorted = store.sessions.sorted {
let a = StatusStyle.sortRank($0.status), b = StatusStyle.sortRank($1.status)
return a == b ? $0.updatedAt > $1.updatedAt : a < b
}
let needs = sorted.filter { $0.status == .awaitingApproval || $0.status == .awaitingInput }
let running = sorted.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
let done = sorted.filter { $0.status.isTerminal }
return [
("Needs you", needs), ("Running", running), ("Done", done),
].filter { !$0.rows.isEmpty }
}
var body: some View {
NavigationStack {
Group {
if store.sessions.isEmpty {
ContentUnavailableView(
"No sessions", systemImage: "square.stack.3d.up",
description: Text(store.connectivity.isLive
? "Start a session on your Mac to see it here."
: "Waiting to connect to \(store.hostName)…"))
} else {
List {
ForEach(grouped, id: \.title) { group in
Section(group.title.uppercased()) {
ForEach(group.rows, id: \.sessionID) { summary in
NavigationLink {
SessionDetailView(sessionID: summary.sessionID)
} label: {
SessionRow(summary: summary)
}
}
}
}
}
.listStyle(.insetGrouped)
.refreshable { store.refreshSessions() }
}
}
.navigationTitle("Sessions")
.safeAreaInset(edge: .bottom) {
ConnectionChip().padding(.bottom, 8)
}
}
}
}
struct SessionRow: View {
let summary: WireSessionSummary
var body: some View {
HStack(spacing: 12) {
Image(systemName: StatusStyle.symbol(summary.status))
.foregroundStyle(StatusStyle.color(summary.status))
.font(.title3)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if let diff = summary.diffStat, diff.filesChanged > 0 {
Text("+\(diff.added)/−\(diff.removed)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
@@ -0,0 +1,79 @@
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var store: RemoteStore
@State private var showScanner = false
var body: some View {
NavigationStack {
Form {
Section("Connection") {
LabeledContent("Status", value: store.connectivity.label)
if !store.hostName.isEmpty {
LabeledContent("Mac", value: store.hostName)
}
Button("Reconnect") { store.reconnect() }
.disabled(!store.isPaired)
}
Section("This device") {
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
LabeledContent("Key fingerprint", value: store.deviceFingerprint)
.font(.footnote.monospaced())
}
Section {
Button {
showScanner = true
} label: {
Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
}
if store.isPaired {
Button(role: .destructive) { store.unpair() } label: {
Label("Unpair this device", systemImage: "minus.circle")
}
}
} footer: {
Text("Code and diffs are end-to-end encrypted. A relay (later) can't read them.")
}
}
.navigationTitle("Settings")
.sheet(isPresented: $showScanner) {
PairingScannerView { payload in
showScanner = false
store.pair(with: payload)
}
}
}
}
}
/// Shown before pairing (UX_IOS §7): explain the flow and launch the scanner.
struct PairingIntroView: View {
@EnvironmentObject var store: RemoteStore
@State private var showScanner = false
var body: some View {
VStack(spacing: 24) {
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
Text("Pair with your Mac").font(.title2.weight(.semibold))
Text("On your Mac, open Nucleic ▸ Settings ▸ Add iPhone to show a QR code, then scan it here.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal, 32)
Button {
showScanner = true
} label: {
Label("Scan QR code", systemImage: "camera").font(.headline).padding(.horizontal)
}
.buttonStyle(.borderedProminent)
}
.padding()
.sheet(isPresented: $showScanner) {
PairingScannerView { payload in
showScanner = false
store.pair(with: payload)
}
}
}
}
@@ -0,0 +1,55 @@
import SwiftUI
import NucleicProtocol
/// Same status glyphs/semantics as the Mac (UX_MACOS §1 / UX_IOS §3) so the two devices read
/// identically.
enum StatusStyle {
static func symbol(_ status: SessionStatus) -> String {
switch status {
case .idle: "circle"
case .provisioning: "circle.dotted"
case .running: "circle.lefthalf.filled"
case .awaitingApproval: "exclamationmark.triangle.fill"
case .awaitingInput: "bubble.left"
case .finished: "checkmark.circle.fill"
case .interrupted: "stop.circle"
case .error: "xmark.octagon.fill"
}
}
static func color(_ status: SessionStatus) -> Color {
switch status {
case .awaitingApproval: .orange
case .running, .provisioning: .blue
case .finished: .green
case .error: .red
case .awaitingInput: .purple
case .idle, .interrupted: .secondary
}
}
/// Attention-first ordering for the session list (UX_IOS §3).
static func sortRank(_ status: SessionStatus) -> Int {
switch status {
case .awaitingApproval: 0
case .awaitingInput: 1
case .running, .provisioning: 2
case .idle: 3
case .finished, .interrupted, .error: 4
}
}
}
extension Risk {
var isHigh: Bool { self == .destructive || self == .network }
var label: String { rawValue }
var color: Color {
switch self {
case .destructive, .network: .red
case .execute: .orange
case .write: .yellow
case .readOnly: .secondary
case .unknown: .secondary
}
}
}
@@ -0,0 +1,82 @@
import SwiftUI
import NucleicProtocol
/// Read-friendly rendering of one transcript event (UX_IOS §4). Assistant/user text as
/// bubbles; tool calls as collapsible rows; everything else compact.
struct TranscriptRow: View {
let event: AgentEvent
@State private var expanded = false
var body: some View {
switch event.kind {
case .userText(let chunk):
bubble(chunk.text, role: .user)
case .assistantText(let chunk):
bubble(chunk.text, role: .assistant)
case .thinking(let chunk):
Text(chunk.text)
.font(.callout.italic())
.foregroundStyle(.secondary)
case .toolCallStarted(let call), .toolCallCompleted(let call):
toolRow(name: call.name, detail: call.input.compactSummary)
case .toolResult(let result):
DisclosureGroup("Result") {
Text(result.content.compactSummary)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
.font(.caption)
case .approvalRequested(let req):
Label("Approval requested: \(req.toolName)", systemImage: "exclamationmark.triangle.fill")
.font(.caption).foregroundStyle(.orange)
case .runFinished(let finished):
Label("Run \(finished.outcome.rawValue)", systemImage: "flag.checkered")
.font(.caption).foregroundStyle(.secondary)
case .error(let err):
Label(err.message, systemImage: "xmark.octagon.fill")
.font(.caption).foregroundStyle(.red)
default:
EmptyView()
}
}
private enum Role { case user, assistant }
@ViewBuilder
private func bubble(_ text: String, role: Role) -> some View {
HStack {
if role == .user { Spacer(minLength: 40) }
Text(text)
.padding(10)
.background(role == .user ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 12))
.frame(maxWidth: .infinity, alignment: role == .user ? .trailing : .leading)
if role == .assistant { Spacer(minLength: 40) }
}
}
private func toolRow(name: String, detail: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "wrench.and.screwdriver").font(.caption).foregroundStyle(.secondary)
Text(name).font(.caption.weight(.semibold))
Text(detail).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
}
}
}
extension JSONValue {
/// A one-line gist of a tool input/result for the collapsed row.
var compactSummary: String {
switch self {
case .string(let s): return s
case .object(let o):
if let cmd = o["command"]?.stringValue { return cmd }
if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path }
return o.keys.sorted().joined(separator: ", ")
case .array(let a): return "[\(a.count) items]"
case .number(let n): return String(n)
case .bool(let b): return String(b)
case .null: return "null"
}
}
}