From c4334143b0d687eed4b9db42f5245de99948a96f Mon Sep 17 00:00:00 2001 From: Patrick Ecord Date: Sat, 20 Jun 2026 15:00:12 -0500 Subject: [PATCH] Auto-derive room ID from iCloud account or device hardware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoomIdentity.derive() in TetherKit: ubiquityIdentityToken (iCloud account, portable across devices) → IOKit/identifierForVendor (hardware, device-tied) → Keychain UUID (last resort). No user config needed. Both apps auto-connect on relaunch if server URL was previously saved. Co-Authored-By: Claude Sonnet 4.6 --- ios/MacTetherApp/Sources/MacTetherApp.swift | 2 +- ios/MacTetherApp/Sources/MacTetherStore.swift | 16 ++-- ios/MacTetherApp/Sources/MenuView.swift | 12 ++- ios/TetherApp/Sources/TetherStore.swift | 10 ++- ios/TetherKit/Package.swift | 4 +- .../Sources/TetherKit/RoomIdentity.swift | 73 +++++++++++++++++++ 6 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 ios/TetherKit/Sources/TetherKit/RoomIdentity.swift diff --git a/ios/MacTetherApp/Sources/MacTetherApp.swift b/ios/MacTetherApp/Sources/MacTetherApp.swift index 8658fe5..df4722e 100644 --- a/ios/MacTetherApp/Sources/MacTetherApp.swift +++ b/ios/MacTetherApp/Sources/MacTetherApp.swift @@ -4,7 +4,7 @@ import SwiftUI struct MacTetherApp: App { @State private var store = MacTetherStore() var body: some Scene { - MenuBarExtra("tether", systemImage: store.connected ? "paperclip.circle.fill" : "paperclip.circle") { + MenuBarExtra("tether", systemImage: store.connected ? "link.circle.fill" : "link.circle") { MenuView(store: store) .frame(width: 320) } diff --git a/ios/MacTetherApp/Sources/MacTetherStore.swift b/ios/MacTetherApp/Sources/MacTetherStore.swift index 0824d68..fb207b8 100644 --- a/ios/MacTetherApp/Sources/MacTetherStore.swift +++ b/ios/MacTetherApp/Sources/MacTetherStore.swift @@ -7,10 +7,11 @@ import TetherKit @MainActor @Observable final class MacTetherStore { var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765" - var room: String = UserDefaults.standard.string(forKey: "room") ?? "" + var room: String = RoomIdentity.derive() var feed: [Message] = [] var connected = false var lastError: String? + var hasAttemptedConnect = false private let deviceID = Host.current().localizedName ?? UUID().uuidString private var client: TetherClient? @@ -18,14 +19,19 @@ final class MacTetherStore { private var pollTask: Task? private var lastChangeCount = NSPasteboard.general.changeCount + func connectIfSaved() { + guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return } + connect() + } + func connect() { - guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)), - !room.isEmpty else { - lastError = "Set a server URL and room id." + guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)) else { + hasAttemptedConnect = true + lastError = "Invalid server URL." return } + hasAttemptedConnect = true UserDefaults.standard.set(serverURL, forKey: "serverURL") - UserDefaults.standard.set(room, forKey: "room") disconnect() let source = "macos-\(ProcessInfo.processInfo.hostName)" diff --git a/ios/MacTetherApp/Sources/MenuView.swift b/ios/MacTetherApp/Sources/MenuView.swift index 2999a7c..e178d5d 100644 --- a/ios/MacTetherApp/Sources/MenuView.swift +++ b/ios/MacTetherApp/Sources/MenuView.swift @@ -14,6 +14,7 @@ struct MenuView: View { composer } .padding(8) + .task { store.connectIfSaved() } } private var header: some View { @@ -32,14 +33,11 @@ struct MenuView: View { if !store.connected { TextField("http://tether.lan:8765", text: $store.serverURL) .textFieldStyle(.roundedBorder).font(.caption) - HStack { - TextField("room id (8 hex)", text: $store.room) - .textFieldStyle(.roundedBorder).font(.caption) - Button("Go") { store.connect() } - .buttonStyle(.borderedProminent).controlSize(.small) - } + Button("Connect") { store.connect() } + .buttonStyle(.borderedProminent).controlSize(.small) + .frame(maxWidth: .infinity) } - if let err = store.lastError { + if store.hasAttemptedConnect, let err = store.lastError { Text(err).font(.caption2).foregroundStyle(.red) } } diff --git a/ios/TetherApp/Sources/TetherStore.swift b/ios/TetherApp/Sources/TetherStore.swift index 550032d..91a1175 100644 --- a/ios/TetherApp/Sources/TetherStore.swift +++ b/ios/TetherApp/Sources/TetherStore.swift @@ -8,7 +8,7 @@ import TetherKit @MainActor @Observable final class TetherStore { var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765" - var room: String = UserDefaults.standard.string(forKey: "room") ?? "" + var room: String = RoomIdentity.derive() var feed: [Message] = [] var connected = false var lastError: String? @@ -17,14 +17,18 @@ final class TetherStore { private var client: TetherClient? private var receiveTask: Task? + func connectIfSaved() { + guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return } + connect() + } + func connect() { guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)), !room.isEmpty else { - lastError = "Set a server URL and room id first." + lastError = "Set a server URL first." return } UserDefaults.standard.set(serverURL, forKey: "serverURL") - UserDefaults.standard.set(room, forKey: "room") disconnect() let cfg = TetherClient.Config(baseURL: base, room: room, from: deviceID) diff --git a/ios/TetherKit/Package.swift b/ios/TetherKit/Package.swift index d98429c..3a907c6 100644 --- a/ios/TetherKit/Package.swift +++ b/ios/TetherKit/Package.swift @@ -8,7 +8,9 @@ let package = Package( .library(name: "TetherKit", targets: ["TetherKit"]), ], targets: [ - .target(name: "TetherKit"), + .target(name: "TetherKit", linkerSettings: [ + .linkedFramework("IOKit", .when(platforms: [.macOS])), + ]), .testTarget(name: "TetherKitTests", dependencies: ["TetherKit"]), ] ) diff --git a/ios/TetherKit/Sources/TetherKit/RoomIdentity.swift b/ios/TetherKit/Sources/TetherKit/RoomIdentity.swift new file mode 100644 index 0000000..85baaa9 --- /dev/null +++ b/ios/TetherKit/Sources/TetherKit/RoomIdentity.swift @@ -0,0 +1,73 @@ +import CryptoKit +import Foundation +import Security + +#if os(macOS) +import IOKit +#elseif os(iOS) +import UIKit +#endif + +/// Derives a stable 8-hex room ID with no user configuration. +/// Priority: +/// 1. iCloud account token (`ubiquityIdentityToken`) — portable across the user's devices +/// 2. Hardware UUID — durable to this device even without an iCloud account +/// 3. Keychain-stored UUID — last resort, survives app reinstalls +public enum RoomIdentity { + public static func derive() -> String { + iCloudDerived() ?? hardwareDerived() ?? keychainOrNew() + } + + /// Non-nil when an iCloud account is signed in. Same value on every device + /// signed into the same Apple ID. Changes if the user signs out. + private static func iCloudDerived() -> String? { + guard let token = FileManager.default.ubiquityIdentityToken, + let data = try? NSKeyedArchiver.archivedData(withRootObject: token, + requiringSecureCoding: false) + else { return nil } + return sha8(data) + } + + /// Machine/device hardware UUID — no account needed, no entitlements. + private static func hardwareDerived() -> String? { + #if os(macOS) + let service = IOServiceGetMatchingService(kIOMainPortDefault, + IOServiceMatching("IOPlatformExpertDevice")) + guard service != 0 else { return nil } + defer { IOObjectRelease(service) } + guard let raw = IORegistryEntryCreateCFProperty( + service, "IOPlatformUUID" as CFString, kCFAllocatorDefault, 0), + let uuid = raw.takeRetainedValue() as? String + else { return nil } + return sha8(Data(uuid.utf8)) + #elseif os(iOS) + guard let uuid = UIDevice.current.identifierForVendor?.uuidString else { return nil } + return sha8(Data(uuid.utf8)) + #else + return nil + #endif + } + + /// Generate once, store in Keychain so it survives reinstalls. + private static func keychainOrNew() -> String { + let account = "io.pecord.tether.roomID" + let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecReturnData: true] + var ref: AnyObject? + if SecItemCopyMatching(query as CFDictionary, &ref) == errSecSuccess, + let data = ref as? Data, let stored = String(data: data, encoding: .utf8) { + return stored + } + let new = String(UUID().uuidString.lowercased().filter(\.isHexDigit).prefix(8)) + let add: [CFString: Any] = [kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecValueData: Data(new.utf8)] + SecItemAdd(add as CFDictionary, nil) + return new + } + + private static func sha8(_ data: Data) -> String { + SHA256.hash(data: data).prefix(4).map { String(format: "%02x", $0) }.joined() + } +}