NetDesk 1.5.0 — source release
This commit is contained in:
commit
40318aafdd
227 files changed
+80798
No files matched your search
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user