Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Models/IdentityStore.swift
T
abkslmandClaude Opus 4.8 34aa01a724 iPad: adaptive sidebar+detail shell and Mac-style diff (remote Phases 1-2)
Grow the universal NucleicRemote app into a width-adaptive shell so a
regular-width iPad renders the macOS sidebar+detail IA while the iPhone
keeps its TabView -- both over the same RemoteStore projection (one host
authority, N renderers).

Phase 1 (adaptive shell):
- AdaptiveRootView branches on horizontalSizeClass: CompactRootView (the
  existing iPhone TabView, moved verbatim) vs SplitRootView
  (NavigationSplitView) on regular width.
- SplitSidebar: Home/Projects/To-dos/Settings destinations + sessions
  grouped under their projects (attention-sorted), connection chip footer.
- SplitDetail selects a destination or a session; a selected session reuses
  SessionDetailView keyed .id(sessionID) so switching drives open/close.
- RemoteStore.closeOpen(_:) is now id-guarded so a split-view A->B switch
  (onAppear(B) before onDisappear(A)) can't tear down B's fresh subscription.
- IdentityStore.deviceID idiom-tags the prefix (ipad-/iphone-) for new
  installs so the host lists a paired iPad correctly.

Phase 2 (width tuning + diff):
- readableColumn() caps+centers Home and the transcript on wide layouts;
  a no-op at phone/portrait width.
- SessionDiffView switches on available width (GeometryReader): a Mac-style
  two-pane diff (file list + selected file's patch) on wide/landscape, the
  phone stack otherwise. UnifiedPatch splits the combined patch per file.
  Read-only, same wire, no protocol change.
- Demo diff fixture now carries both files' patches.

iPhone layout and behavior unchanged. Builds clean; verified in the iPad
simulator (demo mode).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 23:59:29 -07:00

108 lines
4.2 KiB
Swift

import Foundation
import Security
import NucleicProtocol
#if canImport(UIKit)
import UIKit
#endif
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
/// reconnect), a display name, and the transport + connection hint from the QR — LAN
/// host:port or the Mac's tailnet IP. The pairing secret is *not* stored — it's one-time.
/// Non-secret, so UserDefaults is fine; the device private key goes to Keychain. The new
/// optional fields decode as nil from a pre-transport record (= LAN).
struct PairedHost: Codable, Equatable {
var deviceID: String
var hostName: String
var hostStaticKey: Data
var fingerprint: String
var lanHost: String?
var lanPort: UInt16?
/// `SyncTransportHint` raw value; nil = LAN (records saved before transports existed).
var transport: String?
var tailnetHost: String?
var tailnetPort: UInt16?
var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan }
}
/// 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 = "xyz.blakeslee.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 = deviceIDPrefix + UUID().uuidString.prefix(8).lowercased()
defaults.set(id, forKey: deviceIDKey)
return id
}
/// Idiom-tagged prefix so the host lists a paired device with the right kind/icon
/// (`ipad-…` vs `iphone-…`). Only stamps *freshly generated* ids — an existing install
/// keeps whatever id it already persisted, so upgrading a phone never changes its identity.
private static var deviceIDPrefix: String {
#if canImport(UIKit)
return UIDevice.current.userInterfaceIdiom == .pad ? "ipad-" : "iphone-"
#else
return "iphone-"
#endif
}
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)
}
}