M4: NucleicRemote iPhone app — SwiftUI client over the shared protocol
A real iOS Xcode app (ios/NucleicRemote) linking the NucleicProtocol SwiftPM library as a local package. Builds for the iOS 27 simulator and launches to the pairing screen. - Transport: NWFrameChannel (NWConnection) + LANDiscovery (Bonjour _nucleic._tcp). - Engine: drives NucleicProtocol.SyncClient (Noise XXpsk0 pair / IK reconnect, hello/welcome, HostMsg→Event stream). - State: RemoteStore (ObservableObject) — the single on-device projection of host state; IdentityStore persists the device identity (Keychain) + pinned host. - UI (UX_IOS): attention-first SessionsView, SessionDetailView (transcript/diff + status-driven action area / composer), ApprovalCardView with Face ID gate on high-risk approvals + allow-always menu, PairingScannerView (AVFoundation QR), SettingsView, connection chip. Same status glyphs/semantics as the Mac. Add-iPhone QR display + server start live on the macOS side (follow-up); push / Live Activity are M5 (needs the relay). gitignore keeps this .xcodeproj despite the blanket *.xcodeproj rule. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,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 */;
|
||||||
|
}
|
||||||
+15
@@ -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 } }
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
Reference in New Issue
Block a user