NetDesk 1.5.0 — source release

This commit is contained in:
NetDesk committed 2026-10-18 02:02:46 +00:00
commit 40318aafdd
227 files changed
+80798

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
# NetDesk flutter_rust_bridge (FRB v2) FFI layer. This crate is the Dart-callable
# surface over `netdesk-core`: the Flutter UI (ui/) calls `connect(...)` plus the
# input/control methods here, and receives a typed event stream (video frames,
# chat, clipboard, lifecycle) over a flutter_rust_bridge StreamSink.
# Developed by Akronlogic (https://akronlogic.com).
[package]
name = "netdesk-frb"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "NetDesk flutter_rust_bridge FFI layer — Dart-callable API over netdesk-core for the Flutter UI. Developed by Akronlogic."
[lib]
name = "netdesk_frb"
# cdylib = the .so/.dll/.dylib the Flutter engine dlopen's on Android/Windows/Linux;
# staticlib = the .a linked into the iOS/macOS app; lib = so the codegen + tests
# can use the crate as a normal Rust library.
crate-type = ["cdylib", "staticlib", "lib"]
# Silence the cosmetic `unexpected_cfgs` warning the `#[frb(sync)]` attribute
# macro emits (it references an internal `frb_expand` cfg); this is the fix
# flutter_rust_bridge documents.
[lints.rust]
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(frb_expand)'] }
[dependencies]
netdesk-core = { path = "../core" }
# Pinned to the installed codegen (flutter_rust_bridge_codegen 2.13.0); FRB
# requires the runtime crate and the codegen to be the same version.
flutter_rust_bridge = "=2.13.0"
tokio = { workspace = true }
+6
View File
@@ -0,0 +1,6 @@
# flutter_rust_bridge v2 codegen config for the NetDesk FFI layer.
# Run from this directory: flutter_rust_bridge_codegen generate
rust_input: crate::api
rust_root: .
dart_output: ../ui/lib/rust
dart_root: ../ui
+632
View File
@@ -0,0 +1,632 @@
//! Dart-callable API (flutter_rust_bridge v2) over `netdesk-core`.
//!
//! Lifecycle from Dart:
//! ```dart
//! final handle = await connect(wsUrl: ..., hostId: ..., password: ..., totp: ...);
//! sessionEvents(handle: handle).listen((ev) { /* NdEvent */ });
//! sendMouse(handle: handle, x: .., y: .., buttons: .., wheel: .., hwheel: ..);
//! disconnect(handle: handle);
//! ```
//!
//! [`connect`] returns a `u64` **session handle** and starts the Tokio +
//! `netdesk_core::connect` work on a background runtime; it is split from
//! [`session_events`] because a flutter_rust_bridge function that takes a
//! `StreamSink` returns a Dart `Stream` (so it cannot also return the handle).
//! `connect` therefore buffers [`NdEvent`]s into a per-handle channel, and
//! `session_events` taps that channel as the Dart-facing stream. All input /
//! control calls ([`send_mouse`], [`send_key`], …) take the handle so the right
//! [`Session`] is found in the global session table.
//!
//! Threading: a single process-wide multi-threaded Tokio runtime owns every
//! session's network + decode task. The live [`Session`] is parked in a global
//! `Mutex<HashMap<u64, Session>>` so the (cheap, synchronous) input/control
//! functions can reach it from the Dart isolate thread without touching async.
use crate::frb_generated::StreamSink;
use flutter_rust_bridge::frb;
use netdesk_core::decode::{best_decoder, best_decoder_for};
use netdesk_core::host::{run_host, HostConfig, HostEvent, HostFrame};
use netdesk_core::{connect as core_connect, crypto, ConnectConfig, NdError, Session, SessionEvent};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use tokio::sync::mpsc;
/// Events streamed from the Rust core to the Flutter UI (the Dart-facing
/// projection of `netdesk_core`'s richer `SessionEvent` — video access units
/// are decoded to paint-ready RGBA frames here).
#[derive(Clone, Debug)]
pub enum NdEvent {
/// The WebSocket is dialing / the handshake is in flight.
Connecting,
/// Session accepted; the remote screen is about to stream.
Connected {
w: u32,
h: u32,
e2e: bool,
host_name: String,
},
/// A decoded, paint-ready frame: tightly-packed RGBA8 (`width*height*4`).
Frame {
width: u32,
height: u32,
rgba: Vec<u8>,
},
/// The session ended (socket closed, host ended it, connect failed, …).
Ended { reason: String },
/// The host refused the session (bad password/TOTP, busy, blocked, …).
Denied { reason: String },
/// A chat message from the host.
ChatMessage { from: String, text: String },
/// Remote clipboard text pushed by the host (RustDesk-style sync).
Clipboard { text: String },
}
/// Process-wide Tokio runtime that owns every session's network + decode task.
fn runtime() -> &'static tokio::runtime::Runtime {
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("netdesk-frb: build tokio runtime")
})
}
/// Live sessions, keyed by the `u64` handle [`connect`] returns.
fn sessions() -> &'static Mutex<HashMap<u64, Session>> {
static SESSIONS: OnceLock<Mutex<HashMap<u64, Session>>> = OnceLock::new();
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Event receivers parked between [`connect`] (the producer) and
/// [`session_events`] (the Dart-facing stream), keyed by the same handle. The
/// channel is unbounded so events emitted before Dart subscribes are buffered,
/// not dropped.
fn pending() -> &'static Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdEvent>>> {
static PENDING: OnceLock<Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdEvent>>>> =
OnceLock::new();
PENDING.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Per-handle "the user asked to stop" flags. The reconnect loop checks this to
/// tell a deliberate [`disconnect`] (leave the session) from a network drop
/// (auto-reconnect). Shared as an `Arc` so the loop keeps its own reference even
/// after `disconnect` removes the map entry.
fn cancels() -> &'static Mutex<HashMap<u64, Arc<AtomicBool>>> {
static CANCELS: OnceLock<Mutex<HashMap<u64, Arc<AtomicBool>>>> = OnceLock::new();
CANCELS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Monotonic source of session handles. Starts at 1 so `0` can mean "none".
static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
/// Expand tightly-packed RGB8 (what the decoders emit) to RGBA8 (what Flutter's
/// `ui.decodeImageFromPixels` / a texture wants), forcing full alpha.
fn rgb_to_rgba(rgb: &[u8], w: usize, h: usize) -> Vec<u8> {
let mut out = vec![0u8; w * h * 4];
for (src, dst) in rgb.chunks_exact(3).zip(out.chunks_exact_mut(4)) {
dst[0] = src[0];
dst[1] = src[1];
dst[2] = src[2];
dst[3] = 0xff;
}
out
}
/// Start a session against `ws_url` / `host_id` with `password` + `totp`, and
/// return its **handle**. The actual connect + handshake + H.264 decode run on
/// the background Tokio runtime; subscribe to the resulting [`NdEvent`]s with
/// [`session_events`] (passing this handle), and drive input / control with the
/// other functions here. A failed connect still returns a valid handle and
/// reports [`NdEvent::Ended`] / [`NdEvent::Denied`] on the stream.
pub fn connect(ws_url: String, host_id: String, password: String, totp: String) -> u64 {
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
let cfg = ConnectConfig {
ws_url,
host_id,
password,
totp,
takeover: false,
};
// UI-facing event channel; `session_events` drains the receiver into a sink.
let (ev_tx, ev_rx) = mpsc::unbounded_channel::<NdEvent>();
pending().lock().unwrap().insert(handle, ev_rx);
// Cancel flag: `disconnect` sets it so the reconnect loop stops instead of
// re-dialing. Kept as an Arc so the loop holds its own reference even after
// `disconnect` removes the map entry.
let cancel = Arc::new(AtomicBool::new(false));
cancels().lock().unwrap().insert(handle, cancel.clone());
runtime().spawn(async move {
// Resilient session: keep the remote alive across network drops, host
// restarts and roaming. On an unexpected disconnect we re-dial the SAME
// host under the SAME handle (so send_mouse/key keep routing) with a
// short backoff, surfacing a `Connecting` blip instead of ending. We
// stop only on a deliberate `disconnect` (cancel) or an auth `Denied`.
let mut backoff: u64 = 1;
let mut first = true;
let mut was_denied = false;
loop {
if cancel.load(Ordering::Relaxed) {
break;
}
if !first {
// reconnecting: tell the UI the link is being re-established.
let _ = ev_tx.send(NdEvent::Connecting);
}
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
let session = match core_connect(cfg.clone(), tx).await {
Ok(s) => s,
Err(NdError::Denied(reason)) => {
// Bad password / TOTP / blocked / host offline: never
// succeeds on retry. Surface the real reason and stop.
let _ = ev_tx.send(NdEvent::Denied { reason });
was_denied = true;
break;
}
Err(_) => {
// Transient (server/host not reachable yet): back off + retry.
if cancel.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(5);
first = false;
continue;
}
};
// Fresh connection: immediate keyframe + park under the handle. A new
// decoder per connection avoids carrying stale reference frames over
// the gap.
session.request_keyframe();
sessions().lock().unwrap().insert(handle, session);
backoff = 1;
first = false;
let mut decoder = best_decoder();
let mut cur_codec: u8 = 0; // 0 = H.264 (proto::VideoCodec)
while let Some(ev) = rx.recv().await {
match ev {
SessionEvent::Connecting => {
let _ = ev_tx.send(NdEvent::Connecting);
}
SessionEvent::Connected {
host_name,
e2e,
w,
h,
} => {
let _ = ev_tx.send(NdEvent::Connected { w, h, e2e, host_name });
}
SessionEvent::Video { au, keyframe, codec, .. } => {
// Follow the host's per-frame codec tag (H.264 / VP9),
// swapping the decoder only on a keyframe.
if codec != cur_codec {
if keyframe {
decoder = best_decoder_for(codec);
cur_codec = codec;
} else {
continue;
}
}
// Feed the access unit to the active decoder; emit a frame
// only when it produces a picture.
if let Ok(Some(frame)) = decoder.decode(&au) {
let rgba = rgb_to_rgba(&frame.rgb, frame.w, frame.h);
let _ = ev_tx.send(NdEvent::Frame {
width: frame.w as u32,
height: frame.h as u32,
rgba,
});
}
}
SessionEvent::ChatMessage { from, text } => {
let _ = ev_tx.send(NdEvent::ChatMessage { from, text });
}
SessionEvent::Clipboard { text } => {
let _ = ev_tx.send(NdEvent::Clipboard { text });
}
SessionEvent::Denied { reason } => {
let _ = ev_tx.send(NdEvent::Denied { reason });
was_denied = true;
break;
}
SessionEvent::Ended { .. } => {
// Connection dropped: leave the inner loop and reconnect
// (unless the user asked to stop, checked below).
break;
}
// Challenged / Audio / Info / SysResult / file-transfer events
// are not surfaced to the mobile UI in this phase.
_ => {}
}
}
sessions().lock().unwrap().remove(&handle);
if was_denied || cancel.load(Ordering::Relaxed) {
break;
}
// Unexpected drop: brief pause, then reconnect (top emits Connecting).
tokio::time::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(5);
}
// Finished for good (user left or auth denied): clean up + close the UI
// stream so Dart's `.listen` onDone fires.
sessions().lock().unwrap().remove(&handle);
cancels().lock().unwrap().remove(&handle);
// Only emit a generic Ended when the user left (cancel). A Denied already
// carried its real reason (wrong password / host offline / busy) - don't
// overwrite it with "disconnected".
if !was_denied {
let _ = ev_tx.send(NdEvent::Ended {
reason: "disconnected".into(),
});
}
});
handle
}
/// Subscribe to the [`NdEvent`] stream for a `handle` returned by [`connect`].
/// In Dart this is `Stream<NdEvent> sessionEvents({required BigInt handle})`.
/// Call it once per handle, right after `connect`.
pub fn session_events(handle: u64, sink: StreamSink<NdEvent>) {
let rx = pending().lock().unwrap().remove(&handle);
if let Some(mut rx) = rx {
runtime().spawn(async move {
while let Some(ev) = rx.recv().await {
if sink.add(ev).is_err() {
break; // Dart dropped the subscription
}
}
});
}
}
/// Run a helper against the live session for `handle`, if it still exists.
fn with_session<R>(handle: u64, f: impl FnOnce(&Session) -> R) -> Option<R> {
sessions().lock().unwrap().get(&handle).map(f)
}
/// Send a pointer event (coordinates in the remote screen's pixel space).
/// `buttons` is the button bitmask, `wheel`/`hwheel` are scroll deltas.
#[frb(sync)]
pub fn send_mouse(handle: u64, x: i32, y: i32, buttons: u32, wheel: f32, hwheel: f32) {
with_session(handle, |s| s.mouse(x, y, buttons, wheel, hwheel));
}
/// Send a key event. `code` is a DOM-style key code (e.g. "KeyA", "Enter").
#[frb(sync)]
pub fn send_key(handle: u64, code: String, down: bool) {
with_session(handle, |s| s.key(&code, down));
}
/// Reconfigure the host encoder: target `fps`, `bitrate` (kbps), resolution `scale`.
#[frb(sync)]
pub fn set_quality(handle: u64, fps: u32, bitrate: u32, scale: f32) {
with_session(handle, |s| s.set_quality(fps, bitrate, scale));
}
/// Switch the captured monitor to index `i` (as advertised by
/// [`session_monitors`]). Immediately asks the host for a fresh keyframe so the
/// new display paints without waiting for the next natural IDR — the messages
/// travel the same WebSocket in order, so the keyframe is of the *new* monitor.
#[frb(sync)]
pub fn monitor_switch(handle: u64, i: i32) {
with_session(handle, |s| {
s.monitor_switch(i);
s.request_keyframe();
});
}
/// A display the host advertised at accept time (index + pixel size).
#[derive(Clone, Debug)]
pub struct NdMonitor {
pub i: i32,
pub w: u32,
pub h: u32,
}
/// The real list of displays the host reported (its `info.monitors`). The UI
/// uses this to show exactly the monitors that exist — never a hardcoded count.
/// Returns empty until the session is accepted, or if the host sent none.
#[frb(sync)]
pub fn session_monitors(handle: u64) -> Vec<NdMonitor> {
with_session(handle, |s| {
s.monitors()
.iter()
.map(|m| NdMonitor {
i: m.i,
w: m.w,
h: m.h,
})
.collect()
})
.unwrap_or_default()
}
/// The monitor index the host is currently capturing (as reported at accept).
#[frb(sync)]
pub fn session_current_monitor(handle: u64) -> i32 {
with_session(handle, |s| s.current_monitor()).unwrap_or(0)
}
/// Send Ctrl+Alt+Del on the host.
#[frb(sync)]
pub fn ctrl_alt_del(handle: u64) {
with_session(handle, |s| s.ctrl_alt_del());
}
/// Send a chat message to the host.
#[frb(sync)]
pub fn send_chat(handle: u64, text: String) {
with_session(handle, |s| s.chat(&text));
}
/// Push text to the remote clipboard (and announce our local clipboard).
#[frb(sync)]
pub fn set_clipboard(handle: u64, text: String) {
with_session(handle, |s| s.clipboard(&text));
}
/// End the session: tell the host, then drop the handle. The event stream
/// receives a final [`NdEvent::Ended`] when the network task unwinds.
#[frb(sync)]
pub fn disconnect(handle: u64) {
// Tell the reconnect loop to stop re-dialing, then tear down the live
// connection. The loop sees the cancel flag after the socket closes and
// exits instead of auto-reconnecting.
if let Some(c) = cancels().lock().unwrap().get(&handle) {
c.store(true, Ordering::Relaxed);
}
pending().lock().unwrap().remove(&handle);
if let Some(s) = sessions().lock().unwrap().remove(&handle) {
s.close();
}
}
// ===========================================================================
// HOST role — let a viewer connect to and control THIS device (Android host).
// Mirrors the viewer API: host_start() returns a handle; host_events() taps the
// input/control stream; host_push_frame() feeds encoded H.264 from the platform
// encoder (MediaProjection+MediaCodec); host_stop() tears it down. The run loop
// auto-reconnects (backoff 1-5s) so the device stays reachable.
// ===========================================================================
/// Dart-facing projection of `netdesk_core::host::HostEvent`.
#[derive(Clone, Debug)]
pub enum NdHostEvent {
/// Registered with the server; the device is reachable at its host id.
Registered,
/// A viewer authenticated; the session is live (inject input, feed frames).
ViewerConnected { e2e: bool },
/// The viewer left; the host stays registered for the next one.
ViewerLeft,
/// Pointer in the captured image's pixel space (buttons = bitmask).
Mouse { x: i32, y: i32, buttons: u32, wheel: f32 },
/// Key: DOM-style code + down/up.
Key { code: String, down: bool },
/// The viewer asked for a fresh keyframe — request one from the encoder.
KeyframeRequested,
/// The viewer changed quality targets.
Quality { fps: u32, bitrate: u32, scale: f32 },
/// The server link dropped; auto-reconnecting.
Reconnecting,
/// The host loop stopped for good (host_stop / fatal).
Ended { reason: String },
}
fn map_host_event(e: HostEvent) -> Option<NdHostEvent> {
Some(match e {
HostEvent::Registered => NdHostEvent::Registered,
HostEvent::ViewerConnected { e2e, .. } => NdHostEvent::ViewerConnected { e2e },
HostEvent::ViewerLeft => NdHostEvent::ViewerLeft,
HostEvent::Mouse { x, y, buttons, wheel } => NdHostEvent::Mouse { x, y, buttons, wheel },
HostEvent::Key { code, down } => NdHostEvent::Key { code, down },
// Relative/game mouse, Unicode (translate-keyboard) text, and the
// annotation overlay are desktop-host input/overlay modes the native
// engine handles directly; the Android host bridge doesn't surface them.
HostEvent::MouseRel { .. } => return None,
HostEvent::Text { .. } => return None,
HostEvent::Annot { .. } => return None,
HostEvent::KeyframeRequested => NdHostEvent::KeyframeRequested,
HostEvent::Quality { fps, bitrate, scale } => NdHostEvent::Quality { fps, bitrate, scale },
HostEvent::Ended { reason } => NdHostEvent::Ended { reason },
// Android host clipboard-to-host / sys / file controls are not wired yet.
HostEvent::Clipboard { .. } => return None,
HostEvent::ClipboardImage { .. } => return None,
HostEvent::Sys { .. } => return None,
HostEvent::File { .. } => return None,
HostEvent::FileChunk { .. } => return None,
// Voice-call playback on the Android host is not wired yet (needs an
// Android AudioTrack sink); the native desktop host handles it.
HostEvent::Voice { .. } => return None,
// Adaptive-QoS telemetry is consumed by the native engine, not the UI.
HostEvent::NetStat { .. } => return None,
// The viewer's audio-source pick is handled by the native capture/audio
// engine; the Android host UI doesn't surface it.
HostEvent::AudioSource { .. } => return None,
// Codec pin is consumed by the native capture engine's codec selector.
HostEvent::PinCodec { .. } => return None,
// Codec capability is consumed by the native engine's codec selector.
HostEvent::ViewerCaps { .. } => return None,
// Monitor switching is handled by the native capture engine (the Android
// host is single-display).
HostEvent::SwitchMonitor { .. } => return None,
// Approve-before-connect is off on Android (cfg.approve=false), so this
// never fires; view-only is enforced inside the core engine already.
HostEvent::ApproveRequest { .. } => return None,
HostEvent::ViewOnly { .. } => return None,
// TCP tunneling is a desktop-host feature; the Android host doesn't offer it.
HostEvent::TunnelOpen { .. } => return None,
HostEvent::TunnelClose { .. } => return None,
HostEvent::TunnelData { .. } => return None,
// Interactive terminal is a desktop-host feature; not on the Android host.
HostEvent::TermOpen { .. } => return None,
HostEvent::TermInput { .. } => return None,
HostEvent::TermResize { .. } => return None,
HostEvent::TermClose { .. } => return None,
})
}
type FrameSlot = Arc<Mutex<Option<mpsc::UnboundedSender<HostFrame>>>>;
/// Per-handle current frame sender (swapped each (re)connect so pushed frames go
/// to the live run, or are dropped between runs).
fn host_frames() -> &'static Mutex<HashMap<u64, FrameSlot>> {
static M: OnceLock<Mutex<HashMap<u64, FrameSlot>>> = OnceLock::new();
M.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Per-handle Dart-facing event receivers, parked until host_events() taps them.
fn host_pending() -> &'static Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdHostEvent>>> {
static M: OnceLock<Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdHostEvent>>>> = OnceLock::new();
M.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Per-handle "stop" flags for the host reconnect loop.
fn host_cancels() -> &'static Mutex<HashMap<u64, Arc<AtomicBool>>> {
static M: OnceLock<Mutex<HashMap<u64, Arc<AtomicBool>>>> = OnceLock::new();
M.get_or_init(|| Mutex::new(HashMap::new()))
}
/// A fresh 9-digit host id to persist on the device (shown to viewers).
#[frb(sync)]
pub fn host_gen_id() -> String {
crypto::gen_host_id()
}
/// A fresh friendly unattended password (no look-alike chars).
#[frb(sync)]
pub fn host_gen_password() -> String {
crypto::gen_password(8)
}
/// Start hosting: register `host_id` with the server and serve one viewer at a
/// time, authenticating against `password`. Returns a handle; drive input/
/// control via [`host_events`], feed video via [`host_push_frame`], stop with
/// [`host_stop`]. Credentials are derived fresh from `password` each run (the
/// challenge carries the salt, so the viewer only needs the password).
pub fn host_start(
ws_url: String,
host_id: String,
name: String,
password: String,
width: u32,
height: u32,
) -> u64 {
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
let (salt_b64, iters, verifier_hex) = crypto::new_host_credentials(&password);
let cfg = HostConfig {
ws_url,
host_id,
name,
salt_b64,
iters,
verifier_hex,
width,
height,
audio: false,
monitors: Vec::new(),
monitor: 0,
// Android host: all channels allowed (the app's own permission flow —
// MediaProjection consent + accessibility — is the gate), no approve
// prompt (the consent dialog already is one).
perms: Default::default(),
approve: false,
totp_secret: String::new(),
};
let frame_slot: FrameSlot = Arc::new(Mutex::new(None));
host_frames().lock().unwrap().insert(handle, frame_slot.clone());
let (ev_tx, ev_rx) = mpsc::unbounded_channel::<NdHostEvent>();
host_pending().lock().unwrap().insert(handle, ev_rx);
let cancel = Arc::new(AtomicBool::new(false));
host_cancels().lock().unwrap().insert(handle, cancel.clone());
runtime().spawn(async move {
// One core-event channel for the whole host lifetime; the mapper forwards
// (and translates) to the Dart-facing stream across reconnects.
let (core_tx, mut core_rx) = mpsc::unbounded_channel::<HostEvent>();
let ev_tx_map = ev_tx.clone();
let mapper = runtime().spawn(async move {
while let Some(e) = core_rx.recv().await {
if let Some(m) = map_host_event(e) {
if ev_tx_map.send(m).is_err() {
break;
}
}
}
});
let mut backoff: u64 = 1;
loop {
if cancel.load(Ordering::Relaxed) {
break;
}
let (ftx, frx) = mpsc::unbounded_channel::<HostFrame>();
*frame_slot.lock().unwrap() = Some(ftx);
// Android host does not push outbound control yet; drop the sender so
// run_host disables that arm.
let (_ctl_tx, ctl_rx) = mpsc::unbounded_channel::<netdesk_core::host::HostControl>();
let _ = run_host(cfg.clone(), frx, core_tx.clone(), ctl_rx).await;
*frame_slot.lock().unwrap() = None; // drop frames while offline
if cancel.load(Ordering::Relaxed) {
break;
}
let _ = ev_tx.send(NdHostEvent::Reconnecting);
tokio::time::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(5);
}
mapper.abort();
host_frames().lock().unwrap().remove(&handle);
host_cancels().lock().unwrap().remove(&handle);
let _ = ev_tx.send(NdHostEvent::Ended {
reason: "stopped".into(),
});
});
handle
}
/// Subscribe to the [`NdHostEvent`] stream for a `handle` from [`host_start`].
pub fn host_events(handle: u64, sink: StreamSink<NdHostEvent>) {
let rx = host_pending().lock().unwrap().remove(&handle);
if let Some(mut rx) = rx {
runtime().spawn(async move {
while let Some(ev) = rx.recv().await {
if sink.add(ev).is_err() {
break;
}
}
});
}
}
/// Feed one encoded H.264 access unit (Annex-B) from the platform encoder. Cheap
/// + non-blocking; dropped when no viewer is connected.
#[frb(sync)]
pub fn host_push_frame(handle: u64, data: Vec<u8>, keyframe: bool) {
if let Some(slot) = host_frames().lock().unwrap().get(&handle) {
if let Some(tx) = slot.lock().unwrap().as_ref() {
// Android host encodes H.264 (MediaCodec) -> codec id 0.
let _ = tx.send(HostFrame { data, keyframe, codec: 0 });
}
}
}
/// Stop hosting: end the reconnect loop and drop the handle.
#[frb(sync)]
pub fn host_stop(handle: u64) {
if let Some(c) = host_cancels().lock().unwrap().get(&handle) {
c.store(true, Ordering::Relaxed);
}
// dropping the frame sender unblocks run_host so the loop can see `cancel`
if let Some(slot) = host_frames().lock().unwrap().get(&handle) {
*slot.lock().unwrap() = None;
}
host_pending().lock().unwrap().remove(&handle);
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
//! NetDesk flutter_rust_bridge FFI layer. The Dart-facing API lives in [`api`];
//! `frb_generated` is produced by `flutter_rust_bridge_codegen generate` and
//! wires the C ABI the Flutter engine calls. Developed by Akronlogic.
pub mod api;
mod frb_generated;