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,85 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import NucleicProtocol
|
||||
|
||||
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
|
||||
/// reconnect), a display name, and an optional LAN hint. The pairing secret is *not* stored —
|
||||
/// it's one-time. Non-secret, so UserDefaults is fine; the device private key goes to Keychain.
|
||||
struct PairedHost: Codable, Equatable {
|
||||
var deviceID: String
|
||||
var hostName: String
|
||||
var hostStaticKey: Data
|
||||
var fingerprint: String
|
||||
var lanHost: String?
|
||||
var lanPort: UInt16?
|
||||
}
|
||||
|
||||
/// Loads/persists this device's long-term `DeviceIdentity` (Keychain) and the pinned host
|
||||
/// (UserDefaults). The identity is generated once on first launch and reused thereafter.
|
||||
enum IdentityStore {
|
||||
private static let keychainAccount = "com.nucleic.remote.identity"
|
||||
private static let pairedHostKey = "nucleic.pairedHost"
|
||||
private static let deviceIDKey = "nucleic.deviceID"
|
||||
|
||||
static func loadOrCreateIdentity() -> DeviceIdentity {
|
||||
if let data = keychainRead(), let identity = try? DeviceIdentity(importingRaw: data) {
|
||||
return identity
|
||||
}
|
||||
let identity = DeviceIdentity()
|
||||
keychainWrite(identity.exportRaw())
|
||||
return identity
|
||||
}
|
||||
|
||||
/// Stable per-install device id (re-used across reconnects so the host can match the pin).
|
||||
static func deviceID() -> String {
|
||||
let defaults = UserDefaults.standard
|
||||
if let existing = defaults.string(forKey: deviceIDKey) { return existing }
|
||||
let id = "iphone-" + UUID().uuidString.prefix(8).lowercased()
|
||||
defaults.set(id, forKey: deviceIDKey)
|
||||
return id
|
||||
}
|
||||
|
||||
static func loadPairedHost() -> PairedHost? {
|
||||
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
|
||||
return try? JSONDecoder().decode(PairedHost.self, from: data)
|
||||
}
|
||||
|
||||
static func savePairedHost(_ host: PairedHost) {
|
||||
if let data = try? JSONEncoder().encode(host) {
|
||||
UserDefaults.standard.set(data, forKey: pairedHostKey)
|
||||
}
|
||||
}
|
||||
|
||||
static func clearPairedHost() {
|
||||
UserDefaults.standard.removeObject(forKey: pairedHostKey)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
private static func keychainRead() -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
|
||||
return item as? Data
|
||||
}
|
||||
|
||||
private static func keychainWrite(_ data: Data) {
|
||||
let delete: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainAccount,
|
||||
]
|
||||
SecItemDelete(delete as CFDictionary)
|
||||
let add: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainAccount,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
|
||||
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
|
||||
typealias WireSessionSummary = NucleicProtocol.SessionSummary
|
||||
|
||||
/// The phone's single source of UI state — a pure projection of the host (UX_IOS §1.2). Owns
|
||||
/// the `SyncClient`, reflects connectivity truth, and exposes the session list + the open
|
||||
/// session's transcript/approvals. No canonical state is invented on-device.
|
||||
@MainActor
|
||||
final class RemoteStore: ObservableObject {
|
||||
enum Connectivity: Equatable {
|
||||
case unpaired
|
||||
case connecting
|
||||
case reconnecting
|
||||
case connected // LAN
|
||||
case hostOffline
|
||||
case failed(String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .unpaired: "Not paired"
|
||||
case .connecting: "Connecting…"
|
||||
case .reconnecting: "Reconnecting…"
|
||||
case .connected: "Connected · LAN"
|
||||
case .hostOffline: "Mac offline"
|
||||
case .failed(let m): m
|
||||
}
|
||||
}
|
||||
var isLive: Bool { self == .connected }
|
||||
}
|
||||
|
||||
@Published private(set) var connectivity: Connectivity = .unpaired
|
||||
@Published private(set) var hostName: String = ""
|
||||
@Published private(set) var sessions: [WireSessionSummary] = []
|
||||
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||
@Published private(set) var grantedScope: DeviceScope = .approve
|
||||
|
||||
// Open session projection.
|
||||
@Published private(set) var openSessionID: SessionID?
|
||||
@Published private(set) var openEvents: [AgentEvent] = []
|
||||
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
||||
|
||||
/// Sessions needing a human (drives the app-icon badge + NEEDS YOU section).
|
||||
var needsYouCount: Int { sessions.filter { $0.status == .awaitingApproval }.count }
|
||||
|
||||
private let identity = IdentityStore.loadOrCreateIdentity()
|
||||
let discovery = LANDiscovery()
|
||||
private var client: SyncClient?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var seenSeq: Set<UInt64> = []
|
||||
private var reconnectAttempts = 0
|
||||
|
||||
var isPaired: Bool { IdentityStore.loadPairedHost() != nil }
|
||||
var deviceFingerprint: String { identity.fingerprint }
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func onAppear() {
|
||||
discovery.start()
|
||||
if isPaired { reconnect() }
|
||||
}
|
||||
|
||||
/// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0,
|
||||
/// and on success pin the host key for future IK reconnects.
|
||||
func pair(with payload: PairingPayload) {
|
||||
teardown()
|
||||
connectivity = .connecting
|
||||
hostName = payload.hostName
|
||||
let deviceID = IdentityStore.deviceID()
|
||||
guard let endpoint = resolveEndpoint(
|
||||
fingerprint: payload.hostStaticKey.fingerprintHex,
|
||||
lanHost: payload.lanHost, lanPort: payload.lanPort)
|
||||
else { connectivity = .failed("No Mac found on this network"); return }
|
||||
|
||||
let channel = makeChannel(endpoint)
|
||||
let client = SyncClient(
|
||||
channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey,
|
||||
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
|
||||
deviceLabel: UIDevice.current.name)
|
||||
self.client = client
|
||||
consume(client, pairingPayload: payload)
|
||||
}
|
||||
|
||||
/// Reconnect to the already-paired host using IK against the pinned static key.
|
||||
func reconnect() {
|
||||
guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
|
||||
teardown()
|
||||
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
||||
hostName = host.hostName
|
||||
guard let endpoint = resolveEndpoint(
|
||||
fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
|
||||
else { connectivity = .hostOffline; scheduleRetry(); return }
|
||||
|
||||
let channel = makeChannel(endpoint)
|
||||
let client = SyncClient(
|
||||
channel: channel, identity: identity, hostStaticKey: host.hostStaticKey,
|
||||
mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name)
|
||||
self.client = client
|
||||
consume(client, pairingPayload: nil)
|
||||
}
|
||||
|
||||
func unpair() {
|
||||
teardown()
|
||||
IdentityStore.clearPairedHost()
|
||||
connectivity = .unpaired
|
||||
sessions = []
|
||||
}
|
||||
|
||||
// MARK: - Intents (UX_IOS §9)
|
||||
|
||||
func open(_ sessionID: SessionID) {
|
||||
openSessionID = sessionID
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
seenSeq.removeAll()
|
||||
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
||||
}
|
||||
|
||||
func closeOpen() {
|
||||
if let id = openSessionID { send(.unsubscribe(id)) }
|
||||
openSessionID = nil
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
}
|
||||
|
||||
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
|
||||
send(.approvalRespond(approval.id, decision))
|
||||
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
|
||||
}
|
||||
|
||||
func sendInput(_ text: String, to sessionID: SessionID) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
send(.sendInput(sessionID, AgentInput(text: trimmed)))
|
||||
}
|
||||
|
||||
func refreshSessions() { send(.listSessions) }
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
private func send(_ msg: ClientMsg) {
|
||||
guard let client else { return }
|
||||
Task { await client.send(msg) }
|
||||
}
|
||||
|
||||
private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel {
|
||||
NWFrameChannel(endpoint: endpoint)
|
||||
}
|
||||
|
||||
private func resolveEndpoint(fingerprint: String?, lanHost: String?, lanPort: UInt16?) -> NWEndpoint? {
|
||||
discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort)
|
||||
}
|
||||
|
||||
private func consume(_ client: SyncClient, pairingPayload: PairingPayload?) {
|
||||
eventTask = Task { [weak self] in
|
||||
let stream = await client.start()
|
||||
for await event in stream {
|
||||
await self?.handle(event, pairingPayload: pairingPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ event: SyncClient.Event, pairingPayload: PairingPayload?) async {
|
||||
switch event {
|
||||
case .connecting:
|
||||
break
|
||||
case .ready(let welcome):
|
||||
reconnectAttempts = 0
|
||||
connectivity = .connected
|
||||
hostName = welcome.host.hostName
|
||||
capabilities = welcome.capabilities
|
||||
grantedScope = welcome.grantedScope
|
||||
if let payload = pairingPayload, let hostKey = await client?.hostKey() {
|
||||
IdentityStore.savePairedHost(PairedHost(
|
||||
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
|
||||
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
|
||||
lanHost: payload.lanHost, lanPort: payload.lanPort))
|
||||
}
|
||||
send(.listSessions)
|
||||
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
|
||||
case .sessionList(let list):
|
||||
sessions = list
|
||||
case .sessionUpdated(let summary):
|
||||
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary }
|
||||
else { sessions.append(summary) }
|
||||
case .snapshot(let snapshot):
|
||||
guard snapshot.summary.sessionID == openSessionID else { break }
|
||||
seenSeq = Set(snapshot.recentEvents.map(\.seq))
|
||||
openEvents = snapshot.recentEvents
|
||||
openApprovals = snapshot.pendingApprovals
|
||||
case .events(let batch):
|
||||
guard batch.sessionID == openSessionID else { break }
|
||||
for e in batch.events where !seenSeq.contains(e.seq) {
|
||||
seenSeq.insert(e.seq)
|
||||
openEvents.append(e)
|
||||
}
|
||||
case .approvalRequested(let req):
|
||||
if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) {
|
||||
openApprovals.append(req)
|
||||
}
|
||||
case .approvalResolved(let resolved):
|
||||
openApprovals.removeAll { $0.id == resolved.id }
|
||||
case .wireError:
|
||||
break // surfaced contextually by callers; not fatal
|
||||
case .failed(let message):
|
||||
connectivity = .failed(message)
|
||||
scheduleRetry()
|
||||
case .closed:
|
||||
if connectivity == .connected { connectivity = .reconnecting }
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRetry() {
|
||||
guard isPaired else { return }
|
||||
reconnectAttempts += 1
|
||||
let delay = min(Double(reconnectAttempts) * 1.5, 10)
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard let self, self.connectivity != .connected else { return }
|
||||
self.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func teardown() {
|
||||
eventTask?.cancel()
|
||||
eventTask = nil
|
||||
if let client { Task { await client.disconnect() } }
|
||||
client = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
/// Same fingerprint scheme as `DeviceIdentity.fingerprint` (first 8 bytes of SHA-256).
|
||||
var fingerprintHex: String {
|
||||
DeviceIdentity.fingerprint(ofStaticKey: self)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user