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,272 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
BF00000000000000000001 /* NucleicProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000001 /* NucleicProtocol */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
FR00000000000000000001 /* NucleicRemote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NucleicRemote.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EX00000000000000000001 /* Exceptions for "NucleicRemote" folder in "NucleicRemote" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = TG00000000000000000001 /* NucleicRemote */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
SG00000000000000000001 /* NucleicRemote */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EX00000000000000000001 /* Exceptions for "NucleicRemote" folder in "NucleicRemote" target */, ); explicitFileTypes = {}; explicitFolders = (); path = NucleicRemote; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
BP00000000000000000003 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BF00000000000000000001 /* NucleicProtocol in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
GR00000000000000000001 = {
isa = PBXGroup;
children = (
SG00000000000000000001 /* NucleicRemote */,
GR00000000000000000002 /* Products */,
);
sourceTree = "<group>";
};
GR00000000000000000002 /* Products */ = {
isa = PBXGroup;
children = (
FR00000000000000000001 /* NucleicRemote.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
TG00000000000000000001 /* NucleicRemote */ = {
isa = PBXNativeTarget;
buildConfigurationList = CL00000000000000000002 /* Build configuration list for PBXNativeTarget "NucleicRemote" */;
buildPhases = (
BP00000000000000000002 /* Sources */,
BP00000000000000000003 /* Frameworks */,
BP00000000000000000004 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
SG00000000000000000001 /* NucleicRemote */,
);
name = NucleicRemote;
packageProductDependencies = (
PD00000000000000000001 /* NucleicProtocol */,
);
productName = NucleicRemote;
productReference = FR00000000000000000001 /* NucleicRemote.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
PJ00000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2700;
LastUpgradeCheck = 2700;
TargetAttributes = {
TG00000000000000000001 = {
CreatedOnToolsVersion = 27.0;
};
};
};
buildConfigurationList = CL00000000000000000001 /* Build configuration list for PBXProject "NucleicRemote" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = GR00000000000000000001;
minimizedProjectReferenceProxies = 1;
packageReferences = (
PR00000000000000000001 /* XCLocalSwiftPackageReference "Nucleic" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = GR00000000000000000002 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
TG00000000000000000001 /* NucleicRemote */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
BP00000000000000000004 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
BP00000000000000000002 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
BC00000000000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
BC00000000000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
BC00000000000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "";
CODE_SIGNING_ALLOWED = NO;
CODE_SIGNING_REQUIRED = NO;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = NucleicRemote/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.nucleic.remote;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
BC00000000000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "";
CODE_SIGNING_ALLOWED = NO;
CODE_SIGNING_REQUIRED = NO;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = NucleicRemote/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.nucleic.remote;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
CL00000000000000000001 /* Build configuration list for PBXProject "NucleicRemote" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BC00000000000000000001 /* Debug */,
BC00000000000000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CL00000000000000000002 /* Build configuration list for PBXNativeTarget "NucleicRemote" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BC00000000000000000003 /* Debug */,
BC00000000000000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
PR00000000000000000001 /* XCLocalSwiftPackageReference "Nucleic" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../..;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
PD00000000000000000001 /* NucleicProtocol */ = {
isa = XCSwiftPackageProductDependency;
productName = NucleicProtocol;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = PJ00000000000000000001 /* Project object */;
}
@@ -0,0 +1,15 @@
{
"originHash" : "8150c8a8a78c15ae48059529aee5044ae234d717fb91b94b39df933cf2fafaa6",
"pins" : [
{
"identity" : "grdb.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/GRDB.swift.git",
"state" : {
"revision" : "9ed8c8457e00ff9c7aedb3bf213f20a2cfdf509e",
"version" : "7.11.0"
}
}
],
"version" : 3
}
@@ -0,0 +1,4 @@
{
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "red" : "0.118", "green" : "0.565", "blue" : "0.553", "alpha" : "1.000" } }, "idiom" : "universal" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -0,0 +1,4 @@
{
"images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Scan the pairing QR code shown on your Mac.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Connect to your Mac running Nucleic on the local network.</string>
<key>NSBonjourServices</key>
<array>
<string>_nucleic._tcp</string>
</array>
</dict>
</plist>
@@ -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)
}
}
@@ -0,0 +1,70 @@
import Foundation
import Network
import NucleicProtocol
/// Browses Bonjour for nucleic hosts on the LAN (`_nucleic._tcp`) and resolves an endpoint to
/// connect to. The phone tries the QR's explicit LAN hint first (fast path), then falls back
/// to a Bonjour match on the host's identity fingerprint.
@MainActor
final class LANDiscovery: ObservableObject {
struct Found: Identifiable, Hashable {
let id: String // endpoint description
let endpoint: NWEndpoint
let fingerprint: String?
let name: String
}
@Published private(set) var hosts: [Found] = []
private var browser: NWBrowser?
func start() {
guard browser == nil else { return }
let params = NWParameters()
params.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjourWithTXTRecord(type: LANService.type, domain: nil), using: params)
self.browser = browser
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in self?.apply(results) }
}
browser.start(queue: .main)
}
func stop() {
browser?.cancel()
browser = nil
hosts = []
}
/// Resolve an endpoint to dial: explicit host:port from the QR if present, else a Bonjour
/// result whose advertised fingerprint matches the pinned host key.
func endpoint(forFingerprint fingerprint: String?, lanHost: String?, lanPort: UInt16?) -> NWEndpoint? {
if let lanHost, let lanPort, let port = NWEndpoint.Port(rawValue: lanPort) {
return .hostPort(host: .init(lanHost), port: port)
}
if let fingerprint, let match = hosts.first(where: { $0.fingerprint == fingerprint }) {
return match.endpoint
}
return hosts.first?.endpoint
}
private func apply(_ results: Set<NWBrowser.Result>) {
hosts = results.map { result in
var fingerprint: String?
var name = "Mac"
if case .bonjour(let txt) = result.metadata {
fingerprint = txt["fp"]
}
if case .service(let serviceName, _, _, _) = result.endpoint {
name = serviceName
}
return Found(
id: "\(result.endpoint)", endpoint: result.endpoint,
fingerprint: fingerprint, name: name)
}
}
}
enum LANService {
static let type = "_nucleic._tcp"
}
@@ -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()
}
}
}
@@ -0,0 +1,32 @@
import SwiftUI
@main
struct NucleicRemoteApp: App {
@StateObject private var store = RemoteStore()
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(store)
.onAppear { store.onAppear() }
}
}
}
struct RootView: View {
@EnvironmentObject var store: RemoteStore
var body: some View {
if store.isPaired {
TabView {
SessionsView()
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
.badge(store.needsYouCount)
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }
}
} else {
PairingIntroView()
}
}
}
@@ -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"
}
}
}
+39
View File
@@ -0,0 +1,39 @@
# NucleicRemote (iPhone client)
The thin iOS remote client for Nucleic (PLAN milestone **M4**). It's a pure projection of the
Mac host over LAN: monitor sessions, read transcripts/diffs, **answer approvals**, and send
follow-up input — scope `approve`. No local git or CLI; the Mac is the single authority
(see [`docs/UX_IOS.md`](../../docs/UX_IOS.md) and [`docs/SYNC_PROTOCOL.md`](../../docs/SYNC_PROTOCOL.md)).
## Architecture
All wire/crypto logic is shared with the Mac via the **`NucleicProtocol`** SwiftPM library
(this Xcode project links it as a local package at `../..`):
- **Transport** — `NWFrameChannel` (NWConnection) + `LANDiscovery` (Bonjour `_nucleic._tcp`).
- **Engine** — `NucleicProtocol.SyncClient` runs the Noise handshake (XXpsk0 to pair, IK to
reconnect), exchanges hello/welcome, and turns `HostMsg`s into a `SyncClient.Event` stream.
- **State** — `RemoteStore` (`ObservableObject`) is the single on-device UI state, a pure
projection of the host. Identity + pinned host live in `IdentityStore` (Keychain + UserDefaults).
- **UI** — SwiftUI: `SessionsView` (attention-first list), `SessionDetailView`
(transcript/diff + status-driven action area), `ApprovalCardView` (Face ID gate on
high-risk), `PairingScannerView` (QR), `SettingsView`.
## Build & run
```sh
# Resolves the local NucleicProtocol package automatically.
xcodebuild -project ios/NucleicRemote/NucleicRemote.xcodeproj \
-scheme NucleicRemote \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
```
Or open `NucleicRemote.xcodeproj` in Xcode and run. To pair, start the sync server on the Mac
(Nucleic ▸ Settings ▸ Add iPhone shows the QR), then scan it. On a real device, both must be
on the same Wi‑Fi.
## Status
The full pair → list → subscribe → approve → reconnect path is implemented and the protocol/
server side is covered by tests in `Tests/NucleicProtocolTests` and `Tests/NucleicCoreTests`.
Push notifications / Live Activity (UX_IOS §5.1/§5.3) are the M5 follow-up (needs the relay).