Merge nucleic/nimble-drift-koala into dev

This commit is contained in:
2026-07-13 00:07:38 -07:00
parent 03ac36779d
commit 6a28d3e1d9
4 changed files with 95 additions and 2 deletions
@@ -401,7 +401,7 @@ final class HostConnection {
let client = SyncClient( let client = SyncClient(
channel: channel, identity: identity, hostStaticKey: hostStaticKey, channel: channel, identity: identity, hostStaticKey: hostStaticKey,
mode: mode, deviceID: deviceID, 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 // 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`. // TestFlight builds are suffixed (…`.canary`), so a hardcoded topic would `BadTopic`.
pushTopic: Bundle.main.bundleIdentifier, pushTopic: Bundle.main.bundleIdentifier,
@@ -461,7 +461,7 @@ final class HostConnection {
.appendingPathComponent("Nucleic", isDirectory: true) .appendingPathComponent("Nucleic", isDirectory: true)
.appendingPathComponent("tailnet", isDirectory: true) .appendingPathComponent("tailnet", isDirectory: true)
return TailnetConfig( return TailnetConfig(
hostName: TailnetConfig.nodeName(for: UIDevice.current.name), hostName: TailnetConfig.nodeName(for: IdentityStore.deviceName()),
stateDirectory: base) stateDirectory: base)
} }
@@ -48,6 +48,7 @@ enum IdentityStore {
/// connect to all of them. /// connect to all of them.
private static let pairedHostsKey = "nucleic.pairedHosts" private static let pairedHostsKey = "nucleic.pairedHosts"
private static let deviceIDKey = "nucleic.deviceID" private static let deviceIDKey = "nucleic.deviceID"
private static let deviceNameKey = "nucleic.deviceName"
static func loadOrCreateIdentity() -> DeviceIdentity { static func loadOrCreateIdentity() -> DeviceIdentity {
if let data = keychainRead(), let identity = try? DeviceIdentity(importingRaw: data) { if let data = keychainRead(), let identity = try? DeviceIdentity(importingRaw: data) {
@@ -67,6 +68,63 @@ enum IdentityStore {
return id 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 /// 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 /// (`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. /// keeps whatever id it already persisted, so upgrading a phone never changes its identity.
@@ -794,6 +794,7 @@ final class RemoteStore: ObservableObject {
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil } var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
var deviceFingerprint: String { identity.fingerprint } var deviceFingerprint: String { identity.fingerprint }
var deviceName: String { IdentityStore.deviceName() }
// MARK: - Lifecycle // MARK: - Lifecycle
@@ -1645,6 +1646,20 @@ final class RemoteStore: ObservableObject {
refreshAggregate() 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 /// 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. /// device" button). Nothing left to show, so the app returns to the pairing intro.
@@ -9,6 +9,9 @@ struct SettingsView: View {
@State private var showScanner = false @State private var showScanner = false
@State private var showManualPair = false @State private var showManualPair = false
@State private var showAddDevice = 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). /// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3).
@State private var pairedHostsToken = UUID() @State private var pairedHostsToken = UUID()
@AppStorage("nucleic.showRawEvents") private var showRaw = false @AppStorage("nucleic.showRawEvents") private var showRaw = false
@@ -101,6 +104,11 @@ struct SettingsView: View {
} }
Section("This device") { Section("This device") {
LabeledContent("Name", value: deviceName)
Button("Rename device") {
deviceNameDraft = deviceName
showRenameDevice = true
}
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized) LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
LabeledContent("Key fingerprint", value: store.deviceFingerprint) LabeledContent("Key fingerprint", value: store.deviceFingerprint)
.font(.footnote.monospaced()) .font(.footnote.monospaced())
@@ -170,6 +178,18 @@ struct SettingsView: View {
.sheet(isPresented: $showAddDevice, onDismiss: { store.cancelPairingCode() }) { .sheet(isPresented: $showAddDevice, onDismiss: { store.cancelPairingCode() }) {
AddDeviceView() 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.")
}
} }
} }