Files
tether/ios/TetherApp/Sources/TetherStore.swift
Patrick Ecord d1750a95d6 core: account-derived rooms — same account → same room everywhere
Add room_for_account(account) = sha256(account)[..4] hex, exported via UniFFI so
every client derives the IDENTICAL room from a shared identity. Clients now
default their room to the account-derived id (falling back to a device id only
when signed out). With the prefilled account, all our devices land in one room
across LAN, the relay, and RTC — so "just us" works cross-network, not only on
the same Wi-Fi. The single canonical Rust impl guarantees the ids match across
Swift/Kotlin/agent (a hand-rolled per-platform hash would risk drift).

Verified: agent and the live Mac app both derive room 360309c8 from
"pecord@gmail.com"; the Mac app publishes there on the public relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:55:28 -05:00

121 lines
4.2 KiB
Swift

import Foundation
import SwiftUI
import UIKit
import enum TetherKit.RoomIdentity // selective: avoids TetherKit.Message clashing
// with the engine's Message (compiled into this target)
// Engine + Message + MessageHandler come from the generated tethercore.swift,
// built directly into this module no `import` needed.
/// UI-facing clipboard item. Decouples the view from the FFI `Message` record
/// and supplies the `id`/`date` SwiftUI's List needs.
struct FeedItem: Identifiable, Equatable {
let id = UUID()
let text: String
let source: String
let date: Date
}
/// Observable bridge between the shared `tethercore.Engine` and SwiftUI.
/// The engine owns the wire protocol; this owns the clipboard side-effects.
@MainActor @Observable
final class TetherStore {
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "https://tether.pecord.io"
// Prefilled shared identity for now it's just us, so all devices pair.
// Becomes the Sign-in-with-Apple `sub` once the dev program clears.
var account: String = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
// Room follows the account, so all our devices share one room everywhere
// (LAN, relay, RTC). Falls back to a device id only when signed out.
var room: String = {
let a = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
return a.isEmpty ? RoomIdentity.derive() : roomForAccount(account: a)
}()
var feed: [FeedItem] = []
var connected = false
var lastError: String?
private let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
private var engine: Engine?
private var bridge: EngineBridge?
func connectIfSaved() {
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
connect()
}
func connect() {
let url = serverURL.trimmingCharacters(in: .whitespaces)
guard !url.isEmpty, !room.isEmpty else {
lastError = "Set a server URL first."
return
}
UserDefaults.standard.set(serverURL, forKey: "serverURL")
UserDefaults.standard.set(account, forKey: "account")
disconnect()
let b = EngineBridge(store: self)
let e = Engine(server: url, room: room, from: deviceID, source: "ios", name: UIDevice.current.name, account: account)
e.start(handler: b)
engine = e
bridge = b
lastError = nil
}
func disconnect() {
engine?.stop()
engine = nil
bridge = nil
connected = false
}
/// Read the system pasteboard (foreground only iOS bans background reads)
/// and push it to the room.
func sendClipboard() {
guard let text = UIPasteboard.general.string, !text.isEmpty else {
lastError = "Clipboard is empty."
return
}
send(text)
}
func send(_ text: String) {
engine?.send(text: text)
}
/// Copy a received message back onto the local pasteboard.
func copyToPasteboard(_ item: FeedItem) {
UIPasteboard.general.string = item.text
}
// Called from the engine bridge, already hopped onto the main actor.
fileprivate func ingest(_ item: FeedItem) {
feed.insert(item, at: 0)
if feed.count > 100 { feed.removeLast() }
}
fileprivate func setConnected(_ value: Bool) {
connected = value
}
}
/// Adapts the engine's callback interface (fired on the Rust runtime thread)
/// onto the main actor for SwiftUI.
private final class EngineBridge: MessageHandler {
weak var store: TetherStore?
init(store: TetherStore) { self.store = store }
func onMessage(msg: Message) {
// RTC-delivered messages carry ts=0 (no server stamp) use now.
let date = msg.ts > 0 ? Date(timeIntervalSince1970: Double(msg.ts) / 1000.0) : Date()
let item = FeedItem(
text: msg.text,
source: msg.source.isEmpty ? "unknown" : msg.source,
date: date
)
Task { @MainActor in self.store?.ingest(item) }
}
func onStatus(connected: Bool) {
Task { @MainActor in self.store?.setConnected(connected) }
}
}