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 {
|
struct MacTetherApp: App {
|
||||||
@State private var store = MacTetherStore()
|
@State private var store = MacTetherStore()
|
||||||
var body: some Scene {
|
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)
|
MenuView(store: store)
|
||||||
.frame(width: 320)
|
.frame(width: 320)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import TetherKit
|
|||||||
@MainActor @Observable
|
@MainActor @Observable
|
||||||
final class MacTetherStore {
|
final class MacTetherStore {
|
||||||
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765"
|
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 feed: [Message] = []
|
||||||
var connected = false
|
var connected = false
|
||||||
var lastError: String?
|
var lastError: String?
|
||||||
|
var hasAttemptedConnect = false
|
||||||
|
|
||||||
private let deviceID = Host.current().localizedName ?? UUID().uuidString
|
private let deviceID = Host.current().localizedName ?? UUID().uuidString
|
||||||
private var client: TetherClient?
|
private var client: TetherClient?
|
||||||
@@ -18,14 +19,19 @@ final class MacTetherStore {
|
|||||||
private var pollTask: Task<Void, Never>?
|
private var pollTask: Task<Void, Never>?
|
||||||
private var lastChangeCount = NSPasteboard.general.changeCount
|
private var lastChangeCount = NSPasteboard.general.changeCount
|
||||||
|
|
||||||
|
func connectIfSaved() {
|
||||||
|
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
func connect() {
|
func connect() {
|
||||||
guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)),
|
guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)) else {
|
||||||
!room.isEmpty else {
|
hasAttemptedConnect = true
|
||||||
lastError = "Set a server URL and room id."
|
lastError = "Invalid server URL."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
hasAttemptedConnect = true
|
||||||
UserDefaults.standard.set(serverURL, forKey: "serverURL")
|
UserDefaults.standard.set(serverURL, forKey: "serverURL")
|
||||||
UserDefaults.standard.set(room, forKey: "room")
|
|
||||||
disconnect()
|
disconnect()
|
||||||
|
|
||||||
let source = "macos-\(ProcessInfo.processInfo.hostName)"
|
let source = "macos-\(ProcessInfo.processInfo.hostName)"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct MenuView: View {
|
|||||||
composer
|
composer
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
|
.task { store.connectIfSaved() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
@@ -32,14 +33,11 @@ struct MenuView: View {
|
|||||||
if !store.connected {
|
if !store.connected {
|
||||||
TextField("http://tether.lan:8765", text: $store.serverURL)
|
TextField("http://tether.lan:8765", text: $store.serverURL)
|
||||||
.textFieldStyle(.roundedBorder).font(.caption)
|
.textFieldStyle(.roundedBorder).font(.caption)
|
||||||
HStack {
|
Button("Connect") { store.connect() }
|
||||||
TextField("room id (8 hex)", text: $store.room)
|
.buttonStyle(.borderedProminent).controlSize(.small)
|
||||||
.textFieldStyle(.roundedBorder).font(.caption)
|
.frame(maxWidth: .infinity)
|
||||||
Button("Go") { store.connect() }
|
|
||||||
.buttonStyle(.borderedProminent).controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let err = store.lastError {
|
if store.hasAttemptedConnect, let err = store.lastError {
|
||||||
Text(err).font(.caption2).foregroundStyle(.red)
|
Text(err).font(.caption2).foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import TetherKit
|
|||||||
@MainActor @Observable
|
@MainActor @Observable
|
||||||
final class TetherStore {
|
final class TetherStore {
|
||||||
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765"
|
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 feed: [Message] = []
|
||||||
var connected = false
|
var connected = false
|
||||||
var lastError: String?
|
var lastError: String?
|
||||||
@@ -17,14 +17,18 @@ final class TetherStore {
|
|||||||
private var client: TetherClient?
|
private var client: TetherClient?
|
||||||
private var receiveTask: Task<Void, Never>?
|
private var receiveTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
func connectIfSaved() {
|
||||||
|
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
func connect() {
|
func connect() {
|
||||||
guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)),
|
guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)),
|
||||||
!room.isEmpty else {
|
!room.isEmpty else {
|
||||||
lastError = "Set a server URL and room id first."
|
lastError = "Set a server URL first."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
UserDefaults.standard.set(serverURL, forKey: "serverURL")
|
UserDefaults.standard.set(serverURL, forKey: "serverURL")
|
||||||
UserDefaults.standard.set(room, forKey: "room")
|
|
||||||
disconnect()
|
disconnect()
|
||||||
|
|
||||||
let cfg = TetherClient.Config(baseURL: base, room: room, from: deviceID)
|
let cfg = TetherClient.Config(baseURL: base, room: room, from: deviceID)
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ let package = Package(
|
|||||||
.library(name: "TetherKit", targets: ["TetherKit"]),
|
.library(name: "TetherKit", targets: ["TetherKit"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "TetherKit"),
|
.target(name: "TetherKit", linkerSettings: [
|
||||||
|
.linkedFramework("IOKit", .when(platforms: [.macOS])),
|
||||||
|
]),
|
||||||
.testTarget(name: "TetherKitTests", dependencies: ["TetherKit"]),
|
.testTarget(name: "TetherKitTests", dependencies: ["TetherKit"]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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