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,66 @@
import Foundation
import Network
import NucleicProtocol
/// Client-side `FrameChannel` over an outbound `NWConnection` (SYNC_PROTOCOL §3.1). De-frames
/// inbound bytes with `FrameAccumulator`; length-prefixes outbound frames. Mirrors the host's
/// `LANChannel` but for the phone's side of the TCP connection.
final class NWFrameChannel: FrameChannel, @unchecked Sendable {
let peerDescription: String
private let connection: NWConnection
private let queue = DispatchQueue(label: "nucleic.remote.channel")
private let accumulator = FrameAccumulator()
private let stream: AsyncStream<Data>
private let continuation: AsyncStream<Data>.Continuation
/// State callbacks so the store can reflect connectivity truth (UX_IOS §6).
var onReady: (@Sendable () -> Void)?
var onFailed: (@Sendable (String) -> Void)?
init(endpoint: NWEndpoint) {
let params = NWParameters.tcp
params.includePeerToPeer = true
connection = NWConnection(to: endpoint, using: params)
peerDescription = "\(endpoint)"
var cont: AsyncStream<Data>.Continuation!
stream = AsyncStream(bufferingPolicy: .unbounded) { cont = $0 }
continuation = cont
connection.stateUpdateHandler = { [weak self] state in
switch state {
case .ready: self?.onReady?()
case .failed(let error): self?.onFailed?("\(error)"); self?.continuation.finish()
case .cancelled: self?.continuation.finish()
default: break
}
}
connection.start(queue: queue)
receiveLoop()
}
func frames() -> AsyncStream<Data> { stream }
func send(_ frame: Data) {
connection.send(content: WireFraming.frame(frame), completion: .idempotent)
}
func close() {
connection.cancel()
continuation.finish()
}
private func receiveLoop() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in
guard let self else { return }
if let data, !data.isEmpty {
if let frames = try? self.accumulator.push(data) {
for frame in frames { self.continuation.yield(frame) }
} else {
self.close(); return
}
}
if isComplete || error != nil { self.continuation.finish(); return }
self.receiveLoop()
}
}
}