M4: NucleicRemote iPhone app — SwiftUI client over the shared protocol
A real iOS Xcode app (ios/NucleicRemote) linking the NucleicProtocol SwiftPM library as a local package. Builds for the iOS 27 simulator and launches to the pairing screen. - Transport: NWFrameChannel (NWConnection) + LANDiscovery (Bonjour _nucleic._tcp). - Engine: drives NucleicProtocol.SyncClient (Noise XXpsk0 pair / IK reconnect, hello/welcome, HostMsg→Event stream). - State: RemoteStore (ObservableObject) — the single on-device projection of host state; IdentityStore persists the device identity (Keychain) + pinned host. - UI (UX_IOS): attention-first SessionsView, SessionDetailView (transcript/diff + status-driven action area / composer), ApprovalCardView with Face ID gate on high-risk approvals + allow-always menu, PairingScannerView (AVFoundation QR), SettingsView, connection chip. Same status glyphs/semantics as the Mac. Add-iPhone QR display + server start live on the macOS side (follow-up); push / Live Activity are M5 (needs the relay). gitignore keeps this .xcodeproj despite the blanket *.xcodeproj rule. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import NucleicProtocol
|
||||
|
||||
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
|
||||
/// reconnect), a display name, and an optional LAN hint. The pairing secret is *not* stored —
|
||||
/// it's one-time. Non-secret, so UserDefaults is fine; the device private key goes to Keychain.
|
||||
struct PairedHost: Codable, Equatable {
|
||||
var deviceID: String
|
||||
var hostName: String
|
||||
var hostStaticKey: Data
|
||||
var fingerprint: String
|
||||
var lanHost: String?
|
||||
var lanPort: UInt16?
|
||||
}
|
||||
|
||||
/// Loads/persists this device's long-term `DeviceIdentity` (Keychain) and the pinned host
|
||||
/// (UserDefaults). The identity is generated once on first launch and reused thereafter.
|
||||
enum IdentityStore {
|
||||
private static let keychainAccount = "com.nucleic.remote.identity"
|
||||
private static let pairedHostKey = "nucleic.pairedHost"
|
||||
private static let deviceIDKey = "nucleic.deviceID"
|
||||
|
||||
static func loadOrCreateIdentity() -> DeviceIdentity {
|
||||
if let data = keychainRead(), let identity = try? DeviceIdentity(importingRaw: data) {
|
||||
return identity
|
||||
}
|
||||
let identity = DeviceIdentity()
|
||||
keychainWrite(identity.exportRaw())
|
||||
return identity
|
||||
}
|
||||
|
||||
/// Stable per-install device id (re-used across reconnects so the host can match the pin).
|
||||
static func deviceID() -> String {
|
||||
let defaults = UserDefaults.standard
|
||||
if let existing = defaults.string(forKey: deviceIDKey) { return existing }
|
||||
let id = "iphone-" + UUID().uuidString.prefix(8).lowercased()
|
||||
defaults.set(id, forKey: deviceIDKey)
|
||||
return id
|
||||
}
|
||||
|
||||
static func loadPairedHost() -> PairedHost? {
|
||||
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
|
||||
return try? JSONDecoder().decode(PairedHost.self, from: data)
|
||||
}
|
||||
|
||||
static func savePairedHost(_ host: PairedHost) {
|
||||
if let data = try? JSONEncoder().encode(host) {
|
||||
UserDefaults.standard.set(data, forKey: pairedHostKey)
|
||||
}
|
||||
}
|
||||
|
||||
static func clearPairedHost() {
|
||||
UserDefaults.standard.removeObject(forKey: pairedHostKey)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
private static func keychainRead() -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
|
||||
return item as? Data
|
||||
}
|
||||
|
||||
private static func keychainWrite(_ data: Data) {
|
||||
let delete: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainAccount,
|
||||
]
|
||||
SecItemDelete(delete as CFDictionary)
|
||||
let add: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainAccount,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user