cleanup: retire dead TetherKit networking, fix iOS UI papercuts, modernize README

- Delete TetherKit's Message/SSEClient/TetherClient + MessageTests — all dead
  since both apps moved to tethercore. TetherKit is now just RoomIdentity; drop
  the empty test target and the now-unused `import TetherKit` from RoomView.
- iOS: Connect button mislabeled "Reconnect" while connected — now "Disconnect"
  and actually disconnects, matching the macOS app.
- iOS: feed timestamps for RTC-delivered messages (ts=0) fell back to 1970;
  use now, matching the macOS bridge.
- README: rewrite from the Go-only story to the shared-core architecture
  (tethercore engine + native clients on every platform) with a layout diagram.

All four targets rebuild clean (core, agent, iOS, macOS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 00:01:10 -05:00
parent 3581c1e86c
commit 38b9b55ac8
8 changed files with 47 additions and 266 deletions

View File

@@ -1,10 +1,27 @@
# tether
Phone ↔ laptop clipboard relay. **v0.3 — WebRTC P2P working.**
Universal clipboard. **Shared Rust engine + native clients on every platform.**
Today: an HTTP+SSE broadcast bus that also bootstraps WebRTC DataChannels
between participants. Once paired, clipboard text flows direct
peer-to-peer with DTLS encryption — the server never sees the payload.
An HTTP+SSE broadcast bus that also bootstraps WebRTC DataChannels between
participants. Once paired, clipboard text flows direct peer-to-peer with DTLS
encryption — the server never sees the payload.
The wire protocol (codec + SSE + RTC + reconnect) lives once, in a Rust crate
**`core/` (`tethercore`)**, exposed via UniFFI. Every native client — iOS,
macOS, the desktop agent, Android — drives that same engine; only the UI and
binding language differ. The browser client and Go server interoperate over the
same wire format.
```
┌──────────────── tethercore (Rust) ────────────────┐
│ Message codec · Transport trait · dedup │
│ ├─ SSE (reqwest) reliable relay floor │
│ └─ RTC (webrtc-rs) direct P2P data channel │
└───────────────────────────────────────────────────┘
iOS (Swift) ─ macOS (Swift) ─ agent (Rust, Linux/Win/Mac) ─ Android (Kotlin)
└──── all via UniFFI bindings ────┘
browser (JS, native WebRTC) ── interoperates over the wire ──┘
```
The roadmap is what makes it interesting: symmetric presence chirps for
self-healing mesh discovery, Sign in with Apple for cross-device
@@ -42,14 +59,22 @@ the buttons.
## Pieces
- `server/` — single Go binary. Embedded HTML page. Exposes:
- `GET /` phone UI
- `POST /api/send` — accept a message
- `GET /api/stream` — SSE feed of every published message
- `GET /healthz`
- `client/` — CLI client. Subscribes to `/api/stream`, prints received
messages to stdout. `-send` for one-shot send.
- `server/web/index.html` — phone UI (paste, send, live feed of incoming).
**Server & web (Go / JS)**
- `server/` — single Go binary. Embedded HTML page. Exposes `GET /` (phone UI),
`POST /api/send`, `GET /api/stream` (SSE), `GET /healthz`, `/metrics`.
- `server/web/index.html` — browser client (paste, send, live feed; native WebRTC).
- `client/` — Go CLI client (`atotto/clipboard`), `-send` for one-shot.
**Shared engine & native clients (Rust / Swift / Kotlin)**
- `core/``tethercore`, the Rust sync engine (SSE + RTC behind a `Transport`
trait, dedup, reconnect). Exposed via UniFFI; `build-apple.sh` /
`build-android.sh` emit the xcframework / `.aar` artifacts. `examples/` prove
it end-to-end.
- `ios/TetherApp` — SwiftUI iOS client on tethercore.
- `ios/MacTetherApp` — SwiftUI macOS client on tethercore (window + menu bar).
- `ios/TetherKit` — thin SPM package, now just `RoomIdentity` (platform room-id).
- `agent/` — headless Rust daemon (tethercore + `arboard`) for macOS/Linux/Windows.
- `android/` — Jetpack Compose client on tethercore (Kotlin/UniFFI).
## Roadmap

View File

@@ -1,5 +1,4 @@
import SwiftUI
import TetherKit
struct RoomView: View {
@Bindable var store: TetherStore
@@ -36,8 +35,10 @@ struct RoomView: View {
TextField("room id (8 hex)", text: $store.room)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button(store.connected ? "Reconnect" : "Connect") { store.connect() }
.buttonStyle(.borderedProminent)
Button(store.connected ? "Disconnect" : "Connect") {
store.connected ? store.disconnect() : store.connect()
}
.buttonStyle(.borderedProminent)
}
if let err = store.lastError {
Text(err).font(.caption).foregroundStyle(.red)

View File

@@ -95,10 +95,12 @@ private final class EngineBridge: MessageHandler {
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(timeIntervalSince1970: Double(msg.ts) / 1000.0)
date: date
)
Task { @MainActor in self.store?.ingest(item) }
}

View File

@@ -1,6 +1,9 @@
// swift-tools-version: 6.0
import PackageDescription
// Reduced to just `RoomIdentity` the wire protocol moved to the shared Rust
// `tethercore` engine (UniFFI). This package only supplies the platform-native
// room-id derivation the engine doesn't (and shouldn't) know about.
let package = Package(
name: "TetherKit",
platforms: [.iOS(.v17), .macOS(.v14)],
@@ -11,6 +14,5 @@ let package = Package(
.target(name: "TetherKit", linkerSettings: [
.linkedFramework("IOKit", .when(platforms: [.macOS])),
]),
.testTarget(name: "TetherKitTests", dependencies: ["TetherKit"]),
]
)

View File

@@ -1,75 +0,0 @@
import Foundation
/// Wire envelope 1:1 with the Go `Message` struct in `server/main.go`.
/// Keep these in lockstep; this is the contract between every tether client.
public struct Message: Codable, Sendable, Identifiable, Equatable {
public enum Kind: String, Codable, Sendable {
case clipboard
case signal // WebRTC SDP / ICE (carried in `signal`)
case presence // discovery chirp
}
public var type: Kind?
public var text: String?
/// Raw WebRTC signaling payload; opaque at this layer. Parsed only by the RTC code.
public var signal: TetherJSON?
public var from: String?
public var to: String?
public var role: String?
public var source: String?
public var room: String?
/// Unix millis, set server-side on publish. Always present on receive.
public var ts: Int64
public var id: String { "\(ts)-\(from ?? "?")-\(text?.hashValue ?? 0)" }
public init(
type: Kind? = nil, text: String? = nil, signal: TetherJSON? = nil,
from: String? = nil, to: String? = nil, role: String? = nil,
source: String? = nil, room: String? = nil, ts: Int64 = 0
) {
self.type = type; self.text = text; self.signal = signal
self.from = from; self.to = to; self.role = role
self.source = source; self.room = room; self.ts = ts
}
public var date: Date { Date(timeIntervalSince1970: Double(ts) / 1000) }
/// A clipboard payload to POST to `/api/send`.
public static func clipboard(_ text: String, room: String, from: String) -> Message {
Message(type: .clipboard, text: text, from: from, room: room)
}
}
/// Minimal JSON value so `signal` round-trips without us modeling the WebRTC SDP/ICE shape.
public enum TetherJSON: Codable, Sendable, Equatable {
case null
case bool(Bool)
case number(Double)
case string(String)
case array([TetherJSON])
case object([String: TetherJSON])
public init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if c.decodeNil() { self = .null }
else if let v = try? c.decode(Bool.self) { self = .bool(v) }
else if let v = try? c.decode(Double.self) { self = .number(v) }
else if let v = try? c.decode(String.self) { self = .string(v) }
else if let v = try? c.decode([TetherJSON].self) { self = .array(v) }
else if let v = try? c.decode([String: TetherJSON].self) { self = .object(v) }
else { throw DecodingError.dataCorruptedError(in: c, debugDescription: "unsupported JSON") }
}
public func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .null: try c.encodeNil()
case .bool(let v): try c.encode(v)
case .number(let v): try c.encode(v)
case .string(let v): try c.encode(v)
case .array(let v): try c.encode(v)
case .object(let v): try c.encode(v)
}
}
}

View File

@@ -1,59 +0,0 @@
import Foundation
/// Minimal `text/event-stream` reader over `URLSession.bytes`.
/// iOS has no native `EventSource`; the server's frames are simple
/// (`event: <type>\ndata: <json>\n\n`, plus `: keepalive` comments), so we
/// parse them directly rather than pulling a dependency.
public struct SSEEvent: Sendable {
public let event: String // SSE event name (the tether message `type`)
public let data: String // raw `data:` payload (JSON)
}
public actor SSEClient {
private let url: URL
private let session: URLSession
public init(url: URL, session: URLSession = .shared) {
self.url = url
self.session = session
}
/// Async stream of parsed SSE events. Terminates when the connection drops
/// or the task is cancelled; callers handle reconnect (see `TetherClient`).
public func events() -> AsyncThrowingStream<SSEEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
var req = URLRequest(url: url)
req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
req.timeoutInterval = .infinity
let (bytes, response) = try await session.bytes(for: req)
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
throw URLError(.badServerResponse)
}
var event = "message"
var data = ""
for try await line in bytes.lines {
if line.isEmpty { // dispatch on blank line
if !data.isEmpty {
continuation.yield(SSEEvent(event: event, data: data))
}
event = "message"; data = ""
} else if line.hasPrefix(":") { // comment / keepalive
continue
} else if line.hasPrefix("event:") {
event = line.dropFirst(6).trimmingCharacters(in: .whitespaces)
} else if line.hasPrefix("data:") {
let chunk = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
data += data.isEmpty ? chunk : "\n" + chunk
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
}

View File

@@ -1,78 +0,0 @@
import Foundation
/// High-level client for a tether room: POST sends, SSE receive with auto-reconnect.
/// Transport-only mirrors the Go `client/` half. No clipboard/UI here so this
/// package stays usable from the app, a Share Extension, and a future Mac client.
public actor TetherClient {
public struct Config: Sendable {
public var baseURL: URL // e.g. http://tether.lan:8765
public var room: String // 8-hex room id from /r/<id>
public var from: String // this device's peer id
public var source: String // platform label, e.g. "iphone-app"
public init(baseURL: URL, room: String, from: String, source: String = "iphone-app") {
self.baseURL = baseURL; self.room = room; self.from = from; self.source = source
}
}
private let config: Config
private let session: URLSession
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
public init(config: Config, session: URLSession = .shared) {
self.config = config
self.session = session
}
private var streamURL: URL {
var c = URLComponents(url: config.baseURL.appending(path: "/api/stream"),
resolvingAgainstBaseURL: false)!
c.queryItems = [URLQueryItem(name: "room", value: config.room)]
return c.url!
}
/// POST a clipboard payload to the room.
public func send(_ text: String) async throws {
var msg = Message.clipboard(text, room: config.room, from: config.from)
msg.source = config.source
var req = URLRequest(url: config.baseURL.appending(path: "/api/send"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(config.source, forHTTPHeaderField: "X-Tether-Source")
req.httpBody = try encoder.encode(msg)
let (_, resp) = try await session.data(for: req)
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw URLError(.badServerResponse)
}
}
/// Subscribe to the room's clipboard messages, reconnecting on drop with backoff.
/// Yields only `clipboard` messages; signal/presence are handled elsewhere.
public func clipboardStream() -> AsyncStream<Message> {
AsyncStream { continuation in
let task = Task {
var backoff: UInt64 = 500_000_000 // 0.5s
while !Task.isCancelled {
do {
let sse = SSEClient(url: streamURL, session: session)
for try await ev in await sse.events() {
guard ev.event == "clipboard" || ev.event == "message" else { continue }
if let data = ev.data.data(using: .utf8),
let msg = try? decoder.decode(Message.self, from: data),
msg.from != config.from { // skip our own echo
continuation.yield(msg)
}
}
backoff = 500_000_000 // clean EOF reset
} catch {
if Task.isCancelled { break }
}
try? await Task.sleep(nanoseconds: backoff)
backoff = min(backoff * 2, 10_000_000_000) // cap 10s
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
}

View File

@@ -1,37 +0,0 @@
import Testing
import Foundation
@testable import TetherKit
@Suite("Message wire format")
struct MessageTests {
@Test("decodes a server clipboard frame")
func decodeClipboard() throws {
let json = #"{"type":"clipboard","text":"hello","from":"peerA","source":"macos-chrome","room":"a1b2c3d4","ts":1718900000000}"#
let m = try JSONDecoder().decode(Message.self, from: Data(json.utf8))
#expect(m.type == .clipboard)
#expect(m.text == "hello")
#expect(m.room == "a1b2c3d4")
#expect(m.ts == 1_718_900_000_000)
}
@Test("omits empty fields like the Go omitempty tags")
func encodeOmitsEmpty() throws {
let m = Message.clipboard("hi", room: "room1", from: "me")
let data = try JSONEncoder().encode(m)
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["type"] as? String == "clipboard")
#expect(obj["text"] as? String == "hi")
#expect(obj["to"] == nil) // never set must not serialize
#expect(obj["signal"] == nil)
}
@Test("preserves an opaque WebRTC signal blob")
func roundTripSignal() throws {
let json = #"{"type":"signal","from":"p","signal":{"sdp":"v=0","candidates":[1,2]},"ts":1}"#
let m = try JSONDecoder().decode(Message.self, from: Data(json.utf8))
#expect(m.type == .signal)
let re = try JSONEncoder().encode(m)
let back = try JSONDecoder().decode(Message.self, from: re)
#expect(back.signal == m.signal)
}
}