core: shared Rust sync engine (tethercore) + wire into iOS via UniFFI
Introduce core/ — a Rust crate owning the wire protocol (Message codec, SSE client, reconnect/backoff, send) behind a 4-method seam exported via UniFFI: Engine::new / start(handler) / send / stop. This is the single sync engine meant to back all clients; desktop links it directly, iOS binds an .xcframework, Android will bind an .aar. RTC mesh + the signal envelope are deliberately out of scope for v1 (SSE-only). Verified end-to-end against the live server: examples/roundtrip.rs has a subscriber engine receive a message published by a sender engine. iOS now links it: TetherStore drops TetherKit's TetherClient/SSEClient/ Message and drives tethercore.Engine instead (RoomIdentity still from TetherKit via selective import to avoid the Message name clash). The app builds and links the Rust staticlib for device. Artifacts (xcframework, Swift bindings) are generated by core/build-ios.sh and git-ignored; commit source + script, not the ~80MB static libs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
/target/
|
||||||
|
/bindings/
|
||||||
|
/build-xcframework/
|
||||||
|
/*.xcframework
|
||||||
Generated
+1939
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "tethercore"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Shared tether sync engine: codec + SSE client + reconnect, FFI-exported via UniFFI."
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# lib → examples/tests can link it natively
|
||||||
|
# staticlib → iOS .a / desktop agent
|
||||||
|
# cdylib → Android .so / bindgen library mode
|
||||||
|
crate-type = ["lib", "staticlib", "cdylib"]
|
||||||
|
name = "tethercore"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "uniffi-bindgen"
|
||||||
|
path = "src/bin/uniffi-bindgen.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
uniffi = { version = "0.28", features = ["cli"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "time", "sync", "macros"] }
|
||||||
|
futures-util = "0.3"
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds the tethercore Rust engine for iOS and refreshes the artifacts the
|
||||||
|
# Xcode app consumes: the Swift bindings + the TetherCore.xcframework.
|
||||||
|
# These outputs are git-ignored (binaries) / regenerated — run this after any
|
||||||
|
# change to core/src, then build the app in Xcode.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
APP_DIR="../ios/TetherApp"
|
||||||
|
DEVICE=aarch64-apple-ios
|
||||||
|
SIM=aarch64-apple-ios-sim
|
||||||
|
|
||||||
|
echo "▸ building static libs ($DEVICE, $SIM)…"
|
||||||
|
rustup target add "$DEVICE" "$SIM" >/dev/null 2>&1 || true
|
||||||
|
cargo build --release --target "$DEVICE" --lib
|
||||||
|
cargo build --release --target "$SIM" --lib
|
||||||
|
|
||||||
|
echo "▸ generating Swift bindings…"
|
||||||
|
cargo build --release --lib # host dylib for library-mode bindgen
|
||||||
|
cargo run --release --bin uniffi-bindgen -- \
|
||||||
|
generate --library "target/release/libtethercore.dylib" \
|
||||||
|
--language swift --out-dir bindings/swift
|
||||||
|
|
||||||
|
echo "▸ assembling TetherCore.xcframework…"
|
||||||
|
rm -rf build-xcframework && mkdir -p build-xcframework/headers
|
||||||
|
cp bindings/swift/tethercoreFFI.h build-xcframework/headers/
|
||||||
|
cp bindings/swift/tethercoreFFI.modulemap build-xcframework/headers/module.modulemap
|
||||||
|
rm -rf "$APP_DIR/Vendor/TetherCore.xcframework"
|
||||||
|
mkdir -p "$APP_DIR/Vendor"
|
||||||
|
xcodebuild -create-xcframework \
|
||||||
|
-library "target/$DEVICE/release/libtethercore.a" -headers build-xcframework/headers \
|
||||||
|
-library "target/$SIM/release/libtethercore.a" -headers build-xcframework/headers \
|
||||||
|
-output "$APP_DIR/Vendor/TetherCore.xcframework"
|
||||||
|
|
||||||
|
echo "▸ refreshing Swift wrapper in app…"
|
||||||
|
cp bindings/swift/tethercore.swift "$APP_DIR/Sources/tethercore.swift"
|
||||||
|
|
||||||
|
echo "✅ done — open $APP_DIR and build."
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
//! Proves the engine round-trips against a live tether server, no FFI involved.
|
||||||
|
//! cargo run --example roundtrip -- http://192.168.11.233:8765
|
||||||
|
//! Subscriber engine (from=rx) listens; sender engine (from=tx) publishes;
|
||||||
|
//! the message must come back through on_message.
|
||||||
|
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tethercore::{Engine, Message, MessageHandler};
|
||||||
|
|
||||||
|
struct Collector(mpsc::Sender<Message>);
|
||||||
|
impl MessageHandler for Collector {
|
||||||
|
fn on_message(&self, msg: Message) {
|
||||||
|
let _ = self.0.send(msg);
|
||||||
|
}
|
||||||
|
fn on_status(&self, connected: bool) {
|
||||||
|
eprintln!("[rx] connected={connected}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let server = std::env::args()
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_else(|| "http://192.168.11.233:8765".into());
|
||||||
|
let room = "rust-spike".to_string();
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let sub = Engine::new(server.clone(), room.clone(), "rx".into(), "rust-rx".into());
|
||||||
|
sub.start(Box::new(Collector(tx)));
|
||||||
|
|
||||||
|
// Let the SSE subscription establish before publishing.
|
||||||
|
std::thread::sleep(Duration::from_millis(800));
|
||||||
|
|
||||||
|
let payload = "hello-from-rust-core";
|
||||||
|
let pubr = Engine::new(server, room, "tx".into(), "rust-tx".into());
|
||||||
|
pubr.send(payload.to_string());
|
||||||
|
|
||||||
|
match rx.recv_timeout(Duration::from_secs(5)) {
|
||||||
|
Ok(m) if m.text == payload => {
|
||||||
|
println!("✅ round-trip OK: kind={} from={} source={} ts={}\n text={:?}",
|
||||||
|
m.kind, m.from, m.source, m.ts, m.text);
|
||||||
|
}
|
||||||
|
Ok(m) => {
|
||||||
|
println!("⚠️ received unexpected message: {m:?}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
println!("❌ timed out — no message received (is the server up at the URL?)");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sub.stop();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Library-mode binding generator. Build the crate, then:
|
||||||
|
// cargo run --bin uniffi-bindgen generate \
|
||||||
|
// --library target/<triple>/release/libtethercore.dylib \
|
||||||
|
// --language swift --out-dir bindings/swift
|
||||||
|
fn main() {
|
||||||
|
uniffi::uniffi_bindgen_main()
|
||||||
|
}
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
//! tethercore — the shared sync engine.
|
||||||
|
//!
|
||||||
|
//! Owns the wire protocol (codec + SSE client + reconnect/backoff + send) and
|
||||||
|
//! nothing else: no clipboard, no UI. Those stay native on each platform and
|
||||||
|
//! talk to this engine through a 4-method seam exported via UniFFI:
|
||||||
|
//!
|
||||||
|
//! Engine::new(server, room, from, source)
|
||||||
|
//! engine.start(handler) // inbound clipboard → handler.on_message
|
||||||
|
//! engine.send(text)
|
||||||
|
//! engine.stop()
|
||||||
|
//!
|
||||||
|
//! Desktop links it directly; iOS binds the .xcframework, Android the .aar.
|
||||||
|
//! RTC mesh (the `signal` envelope) is intentionally out of scope for v1.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
|
/// 1:1 with the Go server `Message`, minus the RTC `signal` blob.
|
||||||
|
/// `type` is renamed to `kind` (reserved word in the target languages).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct Message {
|
||||||
|
#[serde(rename = "type", default)]
|
||||||
|
pub kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub from: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub to: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub role: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub room: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ts: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implemented on the foreign side (Swift/Kotlin) or natively (desktop agent).
|
||||||
|
/// All callbacks fire on the engine's runtime thread — marshal to the UI thread
|
||||||
|
/// on the consumer side.
|
||||||
|
#[uniffi::export(callback_interface)]
|
||||||
|
pub trait MessageHandler: Send + Sync {
|
||||||
|
/// A clipboard message from another device in the room (self-echo filtered).
|
||||||
|
fn on_message(&self, msg: Message);
|
||||||
|
/// Connection state changed: true once subscribed, false on drop/error.
|
||||||
|
fn on_status(&self, connected: bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Config {
|
||||||
|
server: String,
|
||||||
|
room: String,
|
||||||
|
from: String,
|
||||||
|
source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(uniffi::Object)]
|
||||||
|
pub struct Engine {
|
||||||
|
cfg: Config,
|
||||||
|
http: reqwest::Client,
|
||||||
|
rt: Arc<tokio::runtime::Runtime>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
impl Engine {
|
||||||
|
/// `server` is the bus base URL, e.g. "http://192.168.11.233:8765".
|
||||||
|
/// `from` is this device's stable id (used for echo suppression);
|
||||||
|
/// `source` is a human label like "macos" / "iphone".
|
||||||
|
#[uniffi::constructor]
|
||||||
|
pub fn new(server: String, room: String, from: String, source: String) -> Arc<Self> {
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio runtime");
|
||||||
|
Arc::new(Self {
|
||||||
|
cfg: Config { server, room, from, source },
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
rt: Arc::new(rt),
|
||||||
|
running: Arc::new(AtomicBool::new(false)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin streaming. Idempotent: a second call while running is a no-op.
|
||||||
|
pub fn start(&self, handler: Box<dyn MessageHandler>) {
|
||||||
|
if self.running.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let handler: Arc<dyn MessageHandler> = Arc::from(handler);
|
||||||
|
let http = self.http.clone();
|
||||||
|
let cfg = self.cfg.clone();
|
||||||
|
let running = self.running.clone();
|
||||||
|
self.rt.spawn(async move {
|
||||||
|
run_loop(http, cfg, running, handler).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish clipboard text to the room. Fire-and-forget.
|
||||||
|
pub fn send(&self, text: String) {
|
||||||
|
let http = self.http.clone();
|
||||||
|
let cfg = self.cfg.clone();
|
||||||
|
self.rt.spawn(async move {
|
||||||
|
if let Err(e) = post(&http, &cfg, &text).await {
|
||||||
|
eprintln!("tethercore: send failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop streaming. The reconnect loop exits at its next checkpoint.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.running.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_loop(
|
||||||
|
http: reqwest::Client,
|
||||||
|
cfg: Config,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
handler: Arc<dyn MessageHandler>,
|
||||||
|
) {
|
||||||
|
let min = Duration::from_millis(500);
|
||||||
|
let max = Duration::from_secs(10);
|
||||||
|
let mut backoff = min;
|
||||||
|
while running.load(Ordering::SeqCst) {
|
||||||
|
match connect_stream(&http, &cfg, &running, &handler).await {
|
||||||
|
Ok(()) => backoff = min, // clean EOF — reconnect promptly
|
||||||
|
Err(e) => {
|
||||||
|
handler.on_status(false);
|
||||||
|
eprintln!("tethercore: stream error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if running.load(Ordering::SeqCst) {
|
||||||
|
tokio::time::sleep(backoff).await;
|
||||||
|
backoff = (backoff * 2).min(max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.on_status(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_stream(
|
||||||
|
http: &reqwest::Client,
|
||||||
|
cfg: &Config,
|
||||||
|
running: &AtomicBool,
|
||||||
|
handler: &Arc<dyn MessageHandler>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/stream?room={}",
|
||||||
|
cfg.server.trim_end_matches('/'),
|
||||||
|
cfg.room
|
||||||
|
);
|
||||||
|
let resp = http
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Tether-Client", &cfg.from)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
handler.on_status(true);
|
||||||
|
|
||||||
|
// Byte-buffer the SSE stream; lines split on '\n' (ASCII, so chunk-safe).
|
||||||
|
let mut stream = resp.bytes_stream();
|
||||||
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
|
let mut data: Vec<u8> = Vec::new();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
if !running.load(Ordering::SeqCst) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
|
||||||
|
while let Some(nl) = buf.iter().position(|&b| b == b'\n') {
|
||||||
|
let mut line: Vec<u8> = buf.drain(..=nl).collect();
|
||||||
|
line.pop(); // '\n'
|
||||||
|
if line.last() == Some(&b'\r') {
|
||||||
|
line.pop();
|
||||||
|
}
|
||||||
|
if line.is_empty() {
|
||||||
|
// Event boundary: flush accumulated data field.
|
||||||
|
if !data.is_empty() {
|
||||||
|
if let Ok(m) = serde_json::from_slice::<Message>(&data) {
|
||||||
|
if m.from != cfg.from {
|
||||||
|
handler.on_message(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.clear();
|
||||||
|
}
|
||||||
|
} else if let Some(rest) = line.strip_prefix(b"data:") {
|
||||||
|
let rest = rest.strip_prefix(b" ").unwrap_or(rest);
|
||||||
|
data.extend_from_slice(rest);
|
||||||
|
}
|
||||||
|
// ':' comments (keepalives) and event:/id: lines are ignored.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post(http: &reqwest::Client, cfg: &Config, text: &str) -> Result<(), String> {
|
||||||
|
let url = format!("{}/api/send", cfg.server.trim_end_matches('/'));
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"type": "clipboard",
|
||||||
|
"text": text,
|
||||||
|
"from": cfg.from,
|
||||||
|
"source": cfg.source,
|
||||||
|
"room": cfg.room,
|
||||||
|
});
|
||||||
|
http.post(&url)
|
||||||
|
.header("X-Tether-Source", &cfg.source)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -9,3 +9,12 @@ DerivedData/
|
|||||||
# Generated Xcode projects (XcodeGen owns project.yml as source of truth)
|
# Generated Xcode projects (XcodeGen owns project.yml as source of truth)
|
||||||
# Tracked intentionally so cloning without xcodegen still opens — keep pbxproj.
|
# Tracked intentionally so cloning without xcodegen still opens — keep pbxproj.
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Generated by core/build-ios.sh — regenerate, don't commit
|
||||||
|
TetherApp/Vendor/
|
||||||
|
TetherApp/Sources/tethercore.swift
|
||||||
|
|
||||||
|
# editor state
|
||||||
|
*.xcuserstate
|
||||||
|
xcuserdata/
|
||||||
|
.swiftpm/
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import TetherKit
|
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.
|
||||||
|
|
||||||
/// Observable bridge between `TetherClient` (transport) and SwiftUI.
|
/// UI-facing clipboard item. Decouples the view from the FFI `Message` record
|
||||||
/// Owns the receive task and the local clipboard side-effects.
|
/// 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
|
@MainActor @Observable
|
||||||
final class TetherStore {
|
final class TetherStore {
|
||||||
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765"
|
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765"
|
||||||
var room: String = RoomIdentity.derive()
|
var room: String = RoomIdentity.derive()
|
||||||
var feed: [Message] = []
|
var feed: [FeedItem] = []
|
||||||
var connected = false
|
var connected = false
|
||||||
var lastError: String?
|
var lastError: String?
|
||||||
|
|
||||||
private let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
|
private let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
|
||||||
private var client: TetherClient?
|
private var engine: Engine?
|
||||||
private var receiveTask: Task<Void, Never>?
|
private var bridge: EngineBridge?
|
||||||
|
|
||||||
func connectIfSaved() {
|
func connectIfSaved() {
|
||||||
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
|
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
|
||||||
@@ -23,31 +35,26 @@ final class TetherStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func connect() {
|
func connect() {
|
||||||
guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)),
|
let url = serverURL.trimmingCharacters(in: .whitespaces)
|
||||||
!room.isEmpty else {
|
guard !url.isEmpty, !room.isEmpty else {
|
||||||
lastError = "Set a server URL first."
|
lastError = "Set a server URL first."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
UserDefaults.standard.set(serverURL, forKey: "serverURL")
|
UserDefaults.standard.set(serverURL, forKey: "serverURL")
|
||||||
disconnect()
|
disconnect()
|
||||||
|
|
||||||
let cfg = TetherClient.Config(baseURL: base, room: room, from: deviceID)
|
let b = EngineBridge(store: self)
|
||||||
let c = TetherClient(config: cfg)
|
let e = Engine(server: url, room: room, from: deviceID, source: "ios")
|
||||||
client = c
|
e.start(handler: b)
|
||||||
connected = true
|
engine = e
|
||||||
|
bridge = b
|
||||||
lastError = nil
|
lastError = nil
|
||||||
receiveTask = Task {
|
|
||||||
for await msg in await c.clipboardStream() {
|
|
||||||
feed.insert(msg, at: 0)
|
|
||||||
if feed.count > 100 { feed.removeLast() }
|
|
||||||
}
|
|
||||||
connected = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
receiveTask?.cancel()
|
engine?.stop()
|
||||||
receiveTask = nil
|
engine = nil
|
||||||
|
bridge = nil
|
||||||
connected = false
|
connected = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,15 +69,41 @@ final class TetherStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func send(_ text: String) {
|
func send(_ text: String) {
|
||||||
guard let c = client else { return }
|
engine?.send(text: text)
|
||||||
Task {
|
|
||||||
do { try await c.send(text) }
|
|
||||||
catch { lastError = "Send failed: \(error.localizedDescription)" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy a received message back onto the local pasteboard.
|
/// Copy a received message back onto the local pasteboard.
|
||||||
func copyToPasteboard(_ msg: Message) {
|
func copyToPasteboard(_ item: FeedItem) {
|
||||||
UIPasteboard.general.string = msg.text
|
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) {
|
||||||
|
let item = FeedItem(
|
||||||
|
text: msg.text,
|
||||||
|
source: msg.source.isEmpty ? "unknown" : msg.source,
|
||||||
|
date: Date(timeIntervalSince1970: Double(msg.ts) / 1000.0)
|
||||||
|
)
|
||||||
|
Task { @MainActor in self.store?.ingest(item) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func onStatus(connected: Bool) {
|
||||||
|
Task { @MainActor in self.store?.setConnected(connected) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,15 @@
|
|||||||
A0B34D3F9849F9BC614CC058 /* TetherStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D1001BBAABF813AB29AD45 /* TetherStore.swift */; };
|
A0B34D3F9849F9BC614CC058 /* TetherStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D1001BBAABF813AB29AD45 /* TetherStore.swift */; };
|
||||||
B00ECEE60ABBEFEDFF548750 /* TetherKit in Frameworks */ = {isa = PBXBuildFile; productRef = 214441C31F40D721B6B47F10 /* TetherKit */; };
|
B00ECEE60ABBEFEDFF548750 /* TetherKit in Frameworks */ = {isa = PBXBuildFile; productRef = 214441C31F40D721B6B47F10 /* TetherKit */; };
|
||||||
CCA52ACB37EF809880064207 /* RoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F373509C266AF62E742C792A /* RoomView.swift */; };
|
CCA52ACB37EF809880064207 /* RoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F373509C266AF62E742C792A /* RoomView.swift */; };
|
||||||
|
EEEAB6E2132E74568361D04E /* TetherCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 20AC83BF63EB7D2A826BFF91 /* TetherCore.xcframework */; };
|
||||||
|
FF510853346E00FA9D3B7552 /* tethercore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5C7A947C4BBA8AF0381677 /* tethercore.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
20AC83BF63EB7D2A826BFF91 /* TetherCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TetherCore.xcframework; path = Vendor/TetherCore.xcframework; sourceTree = "<group>"; };
|
||||||
331B3970CED74956D9BE87FF /* TetherApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = TetherApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
331B3970CED74956D9BE87FF /* TetherApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = TetherApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
41B6BEFA7CFACAAA3507391B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
41B6BEFA7CFACAAA3507391B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
6D5C7A947C4BBA8AF0381677 /* tethercore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tethercore.swift; sourceTree = "<group>"; };
|
||||||
B59E41534347FCDEDED407D6 /* TetherApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TetherApp.swift; sourceTree = "<group>"; };
|
B59E41534347FCDEDED407D6 /* TetherApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TetherApp.swift; sourceTree = "<group>"; };
|
||||||
C4E3CFAF660DDE2C62CD1996 /* TetherKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = TetherKit; path = ../TetherKit; sourceTree = SOURCE_ROOT; };
|
C4E3CFAF660DDE2C62CD1996 /* TetherKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = TetherKit; path = ../TetherKit; sourceTree = SOURCE_ROOT; };
|
||||||
D7D1001BBAABF813AB29AD45 /* TetherStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TetherStore.swift; sourceTree = "<group>"; };
|
D7D1001BBAABF813AB29AD45 /* TetherStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TetherStore.swift; sourceTree = "<group>"; };
|
||||||
@@ -28,6 +32,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
B00ECEE60ABBEFEDFF548750 /* TetherKit in Frameworks */,
|
B00ECEE60ABBEFEDFF548750 /* TetherKit in Frameworks */,
|
||||||
|
EEEAB6E2132E74568361D04E /* TetherCore.xcframework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -40,16 +45,26 @@
|
|||||||
41B6BEFA7CFACAAA3507391B /* Info.plist */,
|
41B6BEFA7CFACAAA3507391B /* Info.plist */,
|
||||||
F373509C266AF62E742C792A /* RoomView.swift */,
|
F373509C266AF62E742C792A /* RoomView.swift */,
|
||||||
B59E41534347FCDEDED407D6 /* TetherApp.swift */,
|
B59E41534347FCDEDED407D6 /* TetherApp.swift */,
|
||||||
|
6D5C7A947C4BBA8AF0381677 /* tethercore.swift */,
|
||||||
D7D1001BBAABF813AB29AD45 /* TetherStore.swift */,
|
D7D1001BBAABF813AB29AD45 /* TetherStore.swift */,
|
||||||
);
|
);
|
||||||
path = Sources;
|
path = Sources;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
47410593516FEA684761565A /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
20AC83BF63EB7D2A826BFF91 /* TetherCore.xcframework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
485FAD0A0B0809694D9B7B77 = {
|
485FAD0A0B0809694D9B7B77 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
5EF696014E5E1F874C5B729B /* Packages */,
|
5EF696014E5E1F874C5B729B /* Packages */,
|
||||||
0565A8CEDDE5C5C0BE113DA3 /* Sources */,
|
0565A8CEDDE5C5C0BE113DA3 /* Sources */,
|
||||||
|
47410593516FEA684761565A /* Frameworks */,
|
||||||
717BFFF9FC0CFF5C5EFFF376 /* Products */,
|
717BFFF9FC0CFF5C5EFFF376 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -137,6 +152,7 @@
|
|||||||
CCA52ACB37EF809880064207 /* RoomView.swift in Sources */,
|
CCA52ACB37EF809880064207 /* RoomView.swift in Sources */,
|
||||||
74D3E9952DD8E0913BE6B299 /* TetherApp.swift in Sources */,
|
74D3E9952DD8E0913BE6B299 /* TetherApp.swift in Sources */,
|
||||||
A0B34D3F9849F9BC614CC058 /* TetherStore.swift in Sources */,
|
A0B34D3F9849F9BC614CC058 /* TetherStore.swift in Sources */,
|
||||||
|
FF510853346E00FA9D3B7552 /* tethercore.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -151,6 +167,10 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"\"Vendor\"",
|
||||||
|
);
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Sources/Info.plist;
|
INFOPLIST_FILE = Sources/Info.plist;
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity = "";
|
INFOPLIST_KEY_NSAppTransportSecurity = "";
|
||||||
@@ -161,6 +181,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1.0;
|
MARKETING_VERSION = 0.1.0;
|
||||||
|
OTHER_LDFLAGS = "-framework Security -framework SystemConfiguration -lresolv";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether;
|
PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -238,6 +259,10 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"\"Vendor\"",
|
||||||
|
);
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Sources/Info.plist;
|
INFOPLIST_FILE = Sources/Info.plist;
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "tether connects to your clipboard relay on the local network.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "tether connects to your clipboard relay on the local network.";
|
||||||
@@ -247,6 +272,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1.0;
|
MARKETING_VERSION = 0.1.0;
|
||||||
|
OTHER_LDFLAGS = "-framework Security -framework SystemConfiguration -lresolv";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether;
|
PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ targets:
|
|||||||
sources:
|
sources:
|
||||||
- Sources
|
- Sources
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: TetherKit
|
- package: TetherKit # RoomIdentity only — networking now lives in TetherCore
|
||||||
|
- framework: Vendor/TetherCore.xcframework
|
||||||
|
embed: false # static lib, nothing to embed
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: io.pecord.tether
|
PRODUCT_BUNDLE_IDENTIFIER: io.pecord.tether
|
||||||
|
# Rust staticlib pulls in SecRandomCopyBytes (ring) + system config (reqwest).
|
||||||
|
OTHER_LDFLAGS: "-framework Security -framework SystemConfiguration -lresolv"
|
||||||
MARKETING_VERSION: "0.1.0"
|
MARKETING_VERSION: "0.1.0"
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
GENERATE_INFOPLIST_FILE: true
|
GENERATE_INFOPLIST_FILE: true
|
||||||
|
|||||||
Reference in New Issue
Block a user