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:
49
README.md
49
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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"]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user