Auto-derive room ID from iCloud account or device hardware
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
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)"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
|
||||
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)
|
||||
|
||||
@@ -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"]),
|
||||
]
|
||||
)
|
||||
|
||||
73
ios/TetherKit/Sources/TetherKit/RoomIdentity.swift
Normal file
73
ios/TetherKit/Sources/TetherKit/RoomIdentity.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user