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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user