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:
@@ -1,10 +1,27 @@
|
|||||||
# tether
|
# 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
|
An HTTP+SSE broadcast bus that also bootstraps WebRTC DataChannels between
|
||||||
between participants. Once paired, clipboard text flows direct
|
participants. Once paired, clipboard text flows direct peer-to-peer with DTLS
|
||||||
peer-to-peer with DTLS encryption — the server never sees the payload.
|
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
|
The roadmap is what makes it interesting: symmetric presence chirps for
|
||||||
self-healing mesh discovery, Sign in with Apple for cross-device
|
self-healing mesh discovery, Sign in with Apple for cross-device
|
||||||
@@ -42,14 +59,22 @@ the buttons.
|
|||||||
|
|
||||||
## Pieces
|
## Pieces
|
||||||
|
|
||||||
- `server/` — single Go binary. Embedded HTML page. Exposes:
|
**Server & web (Go / JS)**
|
||||||
- `GET /` — phone UI
|
- `server/` — single Go binary. Embedded HTML page. Exposes `GET /` (phone UI),
|
||||||
- `POST /api/send` — accept a message
|
`POST /api/send`, `GET /api/stream` (SSE), `GET /healthz`, `/metrics`.
|
||||||
- `GET /api/stream` — SSE feed of every published message
|
- `server/web/index.html` — browser client (paste, send, live feed; native WebRTC).
|
||||||
- `GET /healthz`
|
- `client/` — Go CLI client (`atotto/clipboard`), `-send` for one-shot.
|
||||||
- `client/` — CLI client. Subscribes to `/api/stream`, prints received
|
|
||||||
messages to stdout. `-send` for one-shot send.
|
**Shared engine & native clients (Rust / Swift / Kotlin)**
|
||||||
- `server/web/index.html` — phone UI (paste, send, live feed of incoming).
|
- `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
|
## Roadmap
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import TetherKit
|
|
||||||
|
|
||||||
struct RoomView: View {
|
struct RoomView: View {
|
||||||
@Bindable var store: TetherStore
|
@Bindable var store: TetherStore
|
||||||
@@ -36,8 +35,10 @@ struct RoomView: View {
|
|||||||
TextField("room id (8 hex)", text: $store.room)
|
TextField("room id (8 hex)", text: $store.room)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
Button(store.connected ? "Reconnect" : "Connect") { store.connect() }
|
Button(store.connected ? "Disconnect" : "Connect") {
|
||||||
.buttonStyle(.borderedProminent)
|
store.connected ? store.disconnect() : store.connect()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
if let err = store.lastError {
|
if let err = store.lastError {
|
||||||
Text(err).font(.caption).foregroundStyle(.red)
|
Text(err).font(.caption).foregroundStyle(.red)
|
||||||
|
|||||||
@@ -95,10 +95,12 @@ private final class EngineBridge: MessageHandler {
|
|||||||
init(store: TetherStore) { self.store = store }
|
init(store: TetherStore) { self.store = store }
|
||||||
|
|
||||||
func onMessage(msg: Message) {
|
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(
|
let item = FeedItem(
|
||||||
text: msg.text,
|
text: msg.text,
|
||||||
source: msg.source.isEmpty ? "unknown" : msg.source,
|
source: msg.source.isEmpty ? "unknown" : msg.source,
|
||||||
date: Date(timeIntervalSince1970: Double(msg.ts) / 1000.0)
|
date: date
|
||||||
)
|
)
|
||||||
Task { @MainActor in self.store?.ingest(item) }
|
Task { @MainActor in self.store?.ingest(item) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// swift-tools-version: 6.0
|
// swift-tools-version: 6.0
|
||||||
import PackageDescription
|
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(
|
let package = Package(
|
||||||
name: "TetherKit",
|
name: "TetherKit",
|
||||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||||
@@ -11,6 +14,5 @@ let package = Package(
|
|||||||
.target(name: "TetherKit", linkerSettings: [
|
.target(name: "TetherKit", linkerSettings: [
|
||||||
.linkedFramework("IOKit", .when(platforms: [.macOS])),
|
.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