From 6f24b4200497e89de96afd6a41b21aeebf63937e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 13 Jun 2026 01:12:55 -0700 Subject: [PATCH] =?UTF-8?q?M4:=20NucleicRemote=20iPhone=20app=20=E2=80=94?= =?UTF-8?q?=20SwiftUI=20client=20over=20the=20shared=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../NucleicRemote.xcodeproj/project.pbxproj | 272 ++++++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 15 + .../AccentColor.colorset/Contents.json | 4 + .../AppIcon.appiconset/Contents.json | 4 + .../Assets.xcassets/Contents.json | 1 + NucleicRemote/NucleicRemote/Info.plist | 14 + .../NucleicRemote/Models/IdentityStore.swift | 85 ++++++ .../NucleicRemote/Models/RemoteStore.swift | 242 ++++++++++++++++ .../NucleicRemote/Net/LANDiscovery.swift | 70 +++++ .../NucleicRemote/Net/NWFrameChannel.swift | 66 +++++ .../NucleicRemote/NucleicRemoteApp.swift | 32 +++ .../Views/ApprovalCardView.swift | 113 ++++++++ .../NucleicRemote/Views/ConnectionChip.swift | 25 ++ .../Views/PairingScannerView.swift | 102 +++++++ .../Views/SessionDetailView.swift | 115 ++++++++ .../NucleicRemote/Views/SessionsView.swift | 79 +++++ .../NucleicRemote/Views/SettingsView.swift | 79 +++++ .../NucleicRemote/Views/StatusStyle.swift | 55 ++++ .../NucleicRemote/Views/TranscriptRow.swift | 82 ++++++ NucleicRemote/README.md | 39 +++ 20 files changed, 1494 insertions(+) create mode 100644 NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj create mode 100644 NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 NucleicRemote/NucleicRemote/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 NucleicRemote/NucleicRemote/Assets.xcassets/Contents.json create mode 100644 NucleicRemote/NucleicRemote/Info.plist create mode 100644 NucleicRemote/NucleicRemote/Models/IdentityStore.swift create mode 100644 NucleicRemote/NucleicRemote/Models/RemoteStore.swift create mode 100644 NucleicRemote/NucleicRemote/Net/LANDiscovery.swift create mode 100644 NucleicRemote/NucleicRemote/Net/NWFrameChannel.swift create mode 100644 NucleicRemote/NucleicRemote/NucleicRemoteApp.swift create mode 100644 NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift create mode 100644 NucleicRemote/NucleicRemote/Views/ConnectionChip.swift create mode 100644 NucleicRemote/NucleicRemote/Views/PairingScannerView.swift create mode 100644 NucleicRemote/NucleicRemote/Views/SessionDetailView.swift create mode 100644 NucleicRemote/NucleicRemote/Views/SessionsView.swift create mode 100644 NucleicRemote/NucleicRemote/Views/SettingsView.swift create mode 100644 NucleicRemote/NucleicRemote/Views/StatusStyle.swift create mode 100644 NucleicRemote/NucleicRemote/Views/TranscriptRow.swift create mode 100644 NucleicRemote/README.md diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj new file mode 100644 index 0000000..54bed28 --- /dev/null +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj @@ -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 = ""; }; +/* 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 = ""; + }; + GR00000000000000000002 /* Products */ = { + isa = PBXGroup; + children = ( + FR00000000000000000001 /* NucleicRemote.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..8b86550 --- /dev/null +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json b/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..8e37716 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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 } +} diff --git a/NucleicRemote/NucleicRemote/Assets.xcassets/AppIcon.appiconset/Contents.json b/NucleicRemote/NucleicRemote/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2d72247 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/NucleicRemote/NucleicRemote/Assets.xcassets/Contents.json b/NucleicRemote/NucleicRemote/Assets.xcassets/Contents.json new file mode 100644 index 0000000..46cba7d --- /dev/null +++ b/NucleicRemote/NucleicRemote/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info" : { "author" : "xcode", "version" : 1 } } diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist new file mode 100644 index 0000000..f5d6516 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Info.plist @@ -0,0 +1,14 @@ + + + + + NSCameraUsageDescription + Scan the pairing QR code shown on your Mac. + NSLocalNetworkUsageDescription + Connect to your Mac running Nucleic on the local network. + NSBonjourServices + + _nucleic._tcp + + + diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift new file mode 100644 index 0000000..57e8724 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -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) + } +} diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift new file mode 100644 index 0000000..c9adf71 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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? + private var seenSeq: Set = [] + 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) + } +} diff --git a/NucleicRemote/NucleicRemote/Net/LANDiscovery.swift b/NucleicRemote/NucleicRemote/Net/LANDiscovery.swift new file mode 100644 index 0000000..334dedc --- /dev/null +++ b/NucleicRemote/NucleicRemote/Net/LANDiscovery.swift @@ -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) { + 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" +} diff --git a/NucleicRemote/NucleicRemote/Net/NWFrameChannel.swift b/NucleicRemote/NucleicRemote/Net/NWFrameChannel.swift new file mode 100644 index 0000000..b1db453 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Net/NWFrameChannel.swift @@ -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 + private let continuation: AsyncStream.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.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 { 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() + } + } +} diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift new file mode 100644 index 0000000..2aad284 --- /dev/null +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -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() + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift new file mode 100644 index 0000000..acc960b --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -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" } + } + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/ConnectionChip.swift b/NucleicRemote/NucleicRemote/Views/ConnectionChip.swift new file mode 100644 index 0000000..162f13b --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/ConnectionChip.swift @@ -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 + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift new file mode 100644 index 0000000..5ae082f --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift @@ -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) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift new file mode 100644 index 0000000..1f1cc10 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -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() + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift new file mode 100644 index 0000000..05d1658 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -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) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift new file mode 100644 index 0000000..55129c6 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -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) + } + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/StatusStyle.swift b/NucleicRemote/NucleicRemote/Views/StatusStyle.swift new file mode 100644 index 0000000..7a79cf0 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/StatusStyle.swift @@ -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 + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift new file mode 100644 index 0000000..2d4b5b5 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -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" + } + } +} diff --git a/NucleicRemote/README.md b/NucleicRemote/README.md new file mode 100644 index 0000000..c0f9968 --- /dev/null +++ b/NucleicRemote/README.md @@ -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).