4d8ad01256
The shared-core architecture pays off: platform clients now link ONE Rust library that does the entire lumen/1 protocol, and only add decode/present/input on top. lumen-core: - client.rs (quic feature): NativeClient — QUIC handshake + UDP data plane + input datagrams on internal threads; embedder surface = connect / next_frame / send_input. - abi.rs: lumen_connect / lumen_connection_next_au (borrow-until-next-call, matching lumen_client_poll_frame semantics) / lumen_connection_send_input / lumen_connection_mode / lumen_connection_close. Guarded in the generated header by LUMEN_FEATURE_QUIC (cbindgen [defines] mapping), so the checked-in header is stable across feature sets. - error.rs: append-only LumenStatus additions Timeout (-9) and Closed (-10). - TESTED end-to-end through the C ABI: in-process lumen/1 host, lumen_connect pulls 25 byte-verified frames, sends input, closes (m3.rs::c_abi_connection_roundtrip). Apple client (clients/apple — SCAFFOLD, written on Linux, first Xcode build pending): - scripts/build-xcframework.sh: cargo per Apple target → universal staticlib + header (LUMEN_FEATURE_QUIC pre-defined) + modulemap → LumenCore.xcframework. - Package.swift (LumenKit) + Swift sources: LumenConnection (ABI wrapper), AnnexB (in-band VPS/SPS/PPS → CMVideoFormatDescription, Annex-B → AVCC CMSampleBuffers with DisplayImmediately), StreamView (SwiftUI over AVSampleBufferDisplayLayer — stage-1 presenter that hardware-decodes compressed HEVC itself), InputCapture (GCMouse raw deltas + GCKeyboard HID→VK). - README.md is the full handoff for the next (Mac-side) agent: build steps, ABI contract, first-light test recipe against the Linux host, stage-2 (VT+Metal pacing) plan, and the known host-side gaps (single-session m3-host, no lumen/1 audio yet, gamepad kinds not yet routed in m3's injector, seed-stage trust). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
216 lines
7.9 KiB
Rust
216 lines
7.9 KiB
Rust
//! The embeddable `lumen/1` client connector (M4 groundwork), behind the `quic` feature.
|
|
//!
|
|
//! [`NativeClient::connect`] runs the full client side of the protocol — QUIC handshake
|
|
//! ([`crate::quic`]), UDP data plane ([`crate::session::Session`] on a native thread), input
|
|
//! datagrams — and hands the embedder a dead-simple surface: *pull reassembled access units,
|
|
//! push input events*. This is what the platform clients (SwiftUI/VideoToolbox, Android, …)
|
|
//! link via the C ABI (`lumen_connect` & co. in [`crate::abi`]); `lumen-client-rs` is the
|
|
//! Rust-native consumer of the same flow.
|
|
//!
|
|
//! Threading: one worker thread owns a tokio runtime (QUIC control plane only — design
|
|
//! invariant) plus a blocking data-plane pump; frames cross to the embedder over a bounded
|
|
//! channel. All methods are safe to call from any single embedder thread.
|
|
|
|
use crate::config::{Mode, Role};
|
|
use crate::error::{LumenError, Result};
|
|
use crate::input::InputEvent;
|
|
use crate::quic::{endpoint, io, Hello, Start, Welcome};
|
|
use crate::session::{Frame, Session};
|
|
use crate::transport::UdpTransport;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
/// Frames buffered between the data-plane pump and the embedder. Small: the embedder
|
|
/// (decoder) should drain at frame rate; when it falls behind, the newest frame is dropped
|
|
/// (display freshness over completeness — FEC/keyframes recover).
|
|
const FRAME_QUEUE: usize = 16;
|
|
|
|
pub struct NativeClient {
|
|
frames: Receiver<Frame>,
|
|
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
|
shutdown: Arc<AtomicBool>,
|
|
worker: Option<std::thread::JoinHandle<()>>,
|
|
/// The host-confirmed session mode (from the Welcome).
|
|
pub mode: Mode,
|
|
}
|
|
|
|
impl NativeClient {
|
|
/// Connect to a `lumen/1` host and start the session at (up to) `mode`. Blocks until the
|
|
/// handshake completes or `timeout` elapses.
|
|
pub fn connect(host: &str, port: u16, mode: Mode, timeout: Duration) -> Result<NativeClient> {
|
|
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Frame>(FRAME_QUEUE);
|
|
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
|
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Mode>>();
|
|
let shutdown = Arc::new(AtomicBool::new(false));
|
|
|
|
let host = host.to_string();
|
|
let shutdown_w = shutdown.clone();
|
|
let worker = std::thread::Builder::new()
|
|
.name("lumen-client".into())
|
|
.spawn(move || {
|
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(2)
|
|
.enable_all()
|
|
.build()
|
|
{
|
|
Ok(rt) => rt,
|
|
Err(e) => {
|
|
let _ = ready_tx.send(Err(LumenError::Io(e)));
|
|
return;
|
|
}
|
|
};
|
|
rt.block_on(worker_main(
|
|
host, port, mode, frame_tx, input_rx, ready_tx, shutdown_w,
|
|
));
|
|
})
|
|
.map_err(LumenError::Io)?;
|
|
|
|
let negotiated = match ready_rx.recv_timeout(timeout) {
|
|
Ok(Ok(m)) => m,
|
|
Ok(Err(e)) => return Err(e),
|
|
Err(_) => {
|
|
shutdown.store(true, Ordering::SeqCst);
|
|
return Err(LumenError::Timeout);
|
|
}
|
|
};
|
|
Ok(NativeClient {
|
|
frames: frame_rx,
|
|
input_tx,
|
|
shutdown,
|
|
worker: Some(worker),
|
|
mode: negotiated,
|
|
})
|
|
}
|
|
|
|
/// Pull the next reassembled, FEC-recovered access unit; [`LumenError::NoFrame`] on
|
|
/// timeout, [`LumenError::Closed`]-class errors once the session ended.
|
|
pub fn next_frame(&mut self, timeout: Duration) -> Result<Frame> {
|
|
match self.frames.recv_timeout(timeout) {
|
|
Ok(f) => Ok(f),
|
|
Err(RecvTimeoutError::Timeout) => Err(LumenError::NoFrame),
|
|
Err(RecvTimeoutError::Disconnected) => Err(LumenError::Closed),
|
|
}
|
|
}
|
|
|
|
/// Queue one input event for delivery as a QUIC datagram.
|
|
pub fn send_input(&self, ev: &InputEvent) -> Result<()> {
|
|
self.input_tx.send(*ev).map_err(|_| LumenError::Closed)
|
|
}
|
|
}
|
|
|
|
impl Drop for NativeClient {
|
|
fn drop(&mut self) {
|
|
self.shutdown.store(true, Ordering::SeqCst);
|
|
if let Some(w) = self.worker.take() {
|
|
let _ = w.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The worker: QUIC handshake, then the input task + the blocking data-plane pump.
|
|
async fn worker_main(
|
|
host: String,
|
|
port: u16,
|
|
mode: Mode,
|
|
frame_tx: SyncSender<Frame>,
|
|
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
|
ready_tx: std::sync::mpsc::Sender<Result<Mode>>,
|
|
shutdown: Arc<AtomicBool>,
|
|
) {
|
|
let setup = async {
|
|
let remote: std::net::SocketAddr = format!("{host}:{port}")
|
|
.parse()
|
|
.map_err(|_| LumenError::InvalidArg("host:port"))?;
|
|
let ep = endpoint::client_insecure()
|
|
.map_err(|e| LumenError::Io(std::io::Error::other(e.to_string())))?;
|
|
let conn = ep
|
|
.connect(remote, "lumen")
|
|
.map_err(|_| LumenError::InvalidArg("connect"))?
|
|
.await
|
|
.map_err(|e| LumenError::Io(std::io::Error::other(e.to_string())))?;
|
|
let (mut send, mut recv) = conn
|
|
.open_bi()
|
|
.await
|
|
.map_err(|e| LumenError::Io(std::io::Error::other(e.to_string())))?;
|
|
|
|
io::write_msg(
|
|
&mut send,
|
|
&Hello {
|
|
abi_version: crate::ABI_VERSION,
|
|
mode,
|
|
}
|
|
.encode(),
|
|
)
|
|
.await?;
|
|
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)?;
|
|
|
|
// Reserve our data-plane port, then start the host.
|
|
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
|
let udp_port = probe.local_addr()?.port();
|
|
drop(probe);
|
|
io::write_msg(
|
|
&mut send,
|
|
&Start {
|
|
client_udp_port: udp_port,
|
|
}
|
|
.encode(),
|
|
)
|
|
.await?;
|
|
|
|
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
|
|
let transport =
|
|
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?;
|
|
let session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
|
|
Ok::<_, LumenError>((conn, session, welcome.mode))
|
|
};
|
|
|
|
let (conn, mut session, negotiated) = match setup.await {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
let _ = ready_tx.send(Err(e));
|
|
return;
|
|
}
|
|
};
|
|
let _ = ready_tx.send(Ok(negotiated));
|
|
|
|
// Input task: embedder events → QUIC datagrams.
|
|
let input_conn = conn.clone();
|
|
tokio::spawn(async move {
|
|
while let Some(ev) = input_rx.recv().await {
|
|
let _ = input_conn.send_datagram(ev.encode().to_vec().into());
|
|
}
|
|
});
|
|
|
|
// Watch for connection close → stop the pump.
|
|
{
|
|
let shutdown = shutdown.clone();
|
|
let conn = conn.clone();
|
|
tokio::spawn(async move {
|
|
conn.closed().await;
|
|
shutdown.store(true, Ordering::SeqCst);
|
|
});
|
|
}
|
|
|
|
// Data-plane pump on a blocking thread: poll the session, hand frames to the embedder.
|
|
// try_send drops the newest frame when the embedder lags (freshness over completeness).
|
|
let pump_shutdown = shutdown.clone();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
while !pump_shutdown.load(Ordering::SeqCst) {
|
|
match session.poll_frame() {
|
|
Ok(frame) => {
|
|
let _ = frame_tx.try_send(frame);
|
|
}
|
|
Err(LumenError::NoFrame) => {
|
|
std::thread::sleep(Duration::from_micros(300));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
conn.close(0u32.into(), b"client closed");
|
|
}
|