Files
tether/ios/MacTetherApp/Sources/MacTetherStore.swift
Patrick Ecord c4334143b0 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>
2026-06-20 15:00:12 -05:00

95 lines
3.4 KiB
Swift

import AppKit
import Foundation
import TetherKit
/// macOS side: same TetherClient wire layer, but we CAN poll NSPasteboard
/// in the background so clipboard changes auto-send without user action.
@MainActor @Observable
final class MacTetherStore {
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765"
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?
private var receiveTask: Task<Void, Never>?
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)) else {
hasAttemptedConnect = true
lastError = "Invalid server URL."
return
}
hasAttemptedConnect = true
UserDefaults.standard.set(serverURL, forKey: "serverURL")
disconnect()
let source = "macos-\(ProcessInfo.processInfo.hostName)"
let cfg = TetherClient.Config(baseURL: base, room: room,
from: deviceID, source: "macos-app")
let c = TetherClient(config: cfg)
client = c
connected = true
lastError = nil
// Receive: clipboard messages from peers write to NSPasteboard.
receiveTask = Task {
for await msg in await c.clipboardStream() {
feed.insert(msg, at: 0)
if feed.count > 100 { feed.removeLast() }
if let text = msg.text {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
lastChangeCount = NSPasteboard.general.changeCount // don't echo back
}
}
connected = false
}
// Send: poll NSPasteboard for local changes push to room.
lastChangeCount = NSPasteboard.general.changeCount
pollTask = Task {
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
let count = NSPasteboard.general.changeCount
guard count != lastChangeCount else { continue }
lastChangeCount = count
if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty {
try? await c.send(text)
}
}
}
}
func disconnect() {
receiveTask?.cancel(); receiveTask = nil
pollTask?.cancel(); pollTask = nil
connected = false
}
func send(_ text: String) {
guard let c = client else { return }
Task {
do { try await c.send(text) }
catch { lastError = "Send failed: \(error.localizedDescription)" }
}
}
func copyToPasteboard(_ msg: Message) {
guard let text = msg.text else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
lastChangeCount = NSPasteboard.general.changeCount
}
}