Merge nucleic/nimble-drift-koala into dev
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user