From 6a28d3e1d9f71072587df545cd0c064fa0ff1918 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 13 Jul 2026 00:07:38 -0700 Subject: [PATCH] Merge nucleic/nimble-drift-koala into dev --- .../NucleicRemote/Models/HostConnection.swift | 4 +- .../NucleicRemote/Models/IdentityStore.swift | 58 +++++++++++++++++++ .../NucleicRemote/Models/RemoteStore.swift | 15 +++++ .../NucleicRemote/Views/SettingsView.swift | 20 +++++++ 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 45d4e31..832314c 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -401,7 +401,7 @@ final class HostConnection { let client = SyncClient( channel: channel, identity: identity, hostStaticKey: hostStaticKey, mode: mode, deviceID: deviceID, - deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex, + deviceLabel: IdentityStore.deviceName(), pushToken: PushRegistrar.shared.tokenHex, // Our own bundle id is the exact APNs topic the relay must address; per-channel // TestFlight builds are suffixed (…`.canary`), so a hardcoded topic would `BadTopic`. pushTopic: Bundle.main.bundleIdentifier, @@ -461,7 +461,7 @@ final class HostConnection { .appendingPathComponent("Nucleic", isDirectory: true) .appendingPathComponent("tailnet", isDirectory: true) return TailnetConfig( - hostName: TailnetConfig.nodeName(for: UIDevice.current.name), + hostName: TailnetConfig.nodeName(for: IdentityStore.deviceName()), stateDirectory: base) } diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift index d80eafe..d2240a7 100644 --- a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -48,6 +48,7 @@ enum IdentityStore { /// connect to all of them. private static let pairedHostsKey = "nucleic.pairedHosts" private static let deviceIDKey = "nucleic.deviceID" + private static let deviceNameKey = "nucleic.deviceName" static func loadOrCreateIdentity() -> DeviceIdentity { if let data = keychainRead(), let identity = try? DeviceIdentity(importingRaw: data) { @@ -67,6 +68,63 @@ enum IdentityStore { return id } + /// The user-visible name advertised to every Mac in the mesh. + /// + /// iOS 16+ normally redacts UIDevice.name to the generic "iPhone" / "iPad" for apps + /// without Apple's restricted user-assigned-device-name entitlement. Prefer the real name + /// when the OS supplies one; otherwise create a short, stable per-install name so two phones + /// are distinguishable. The value is persisted separately from deviceID because users may + /// rename it without changing the cryptographic/pairing identity. + static func deviceName() -> String { + let defaults = UserDefaults.standard + if let existing = defaults.string(forKey: deviceNameKey), !existing.isEmpty { + return existing + } + + let name = reportedDeviceName ?? "\(deviceKindName)-\(randomSlug())" + defaults.set(name, forKey: deviceNameKey) + return name + } + + /// Persist a user-supplied mesh label. Returns false for an empty/whitespace-only name. + @discardableResult + static func setDeviceName(_ proposedName: String) -> Bool { + let name = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return false } + UserDefaults.standard.set(String(name.prefix(64)), forKey: deviceNameKey) + return true + } + + /// A non-generic name reported by UIKit, when available. Comparing with the idiom/model names + /// avoids treating iOS's privacy placeholder as if it were useful device identity. + private static var reportedDeviceName: String? { + #if canImport(UIKit) + let device = UIDevice.current + let name = device.name.trimmingCharacters(in: .whitespacesAndNewlines) + let genericNames = ["iPhone", "iPad", device.model, device.localizedModel] + guard !name.isEmpty, + !genericNames.contains(where: { $0.caseInsensitiveCompare(name) == .orderedSame }) + else { return nil } + return name + #else + return nil + #endif + } + + private static var deviceKindName: String { + #if canImport(UIKit) + return UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" + #else + return "iPhone" + #endif + } + + private static func randomSlug() -> String { + // Leave out visually ambiguous characters while retaining a 32^4 name space. + let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") + return String((0..<4).map { _ in alphabet.randomElement()! }) + } + /// 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. diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index f6bb417..4b6410e 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -794,6 +794,7 @@ final class RemoteStore: ObservableObject { var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil } var deviceFingerprint: String { identity.fingerprint } + var deviceName: String { IdentityStore.deviceName() } // MARK: - Lifecycle @@ -1645,6 +1646,20 @@ final class RemoteStore: ObservableObject { refreshAggregate() } + /// Rename this phone in the mesh. Every live connection is deliberately re-handshaken so each + /// Mac updates its paired-device record and republishes the new label to the rest of the mesh. + @discardableResult + func renameDevice(to name: String) -> Bool { + guard IdentityStore.setDeviceName(name) else { return false } + guard !demoMode else { return true } + for host in IdentityStore.pairedHosts() { + connection(for: host).reconnect(to: host) + } + rebuildAggregate() + refreshAggregate() + return true + } + /// Unpair this device entirely — drop every paired Mac and its connection (the "Unpair this /// device" button). Nothing left to show, so the app returns to the pairing intro. diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index b1e146b..838f2c8 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -9,6 +9,9 @@ struct SettingsView: View { @State private var showScanner = false @State private var showManualPair = false @State private var showAddDevice = false + @State private var showRenameDevice = false + @State private var deviceName = IdentityStore.deviceName() + @State private var deviceNameDraft = "" /// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3). @State private var pairedHostsToken = UUID() @AppStorage("nucleic.showRawEvents") private var showRaw = false @@ -101,6 +104,11 @@ struct SettingsView: View { } Section("This device") { + LabeledContent("Name", value: deviceName) + Button("Rename device") { + deviceNameDraft = deviceName + showRenameDevice = true + } LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized) LabeledContent("Key fingerprint", value: store.deviceFingerprint) .font(.footnote.monospaced()) @@ -170,6 +178,18 @@ struct SettingsView: View { .sheet(isPresented: $showAddDevice, onDismiss: { store.cancelPairingCode() }) { AddDeviceView() } + .alert("Rename this device", isPresented: $showRenameDevice) { + TextField("Device name", text: $deviceNameDraft) + Button("Cancel", role: .cancel) {} + Button("Save") { + if store.renameDevice(to: deviceNameDraft) { + deviceName = store.deviceName + } + } + .disabled(deviceNameDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } message: { + Text("This name identifies your iPhone or iPad to every Mac in the mesh.") + } } }