2026-04-13 02:52:32 -03:00
|
|
|
//! Include-based coverage for client app startup reactor behavior.
|
|
|
|
|
//!
|
|
|
|
|
//! Scope: compile `client/src/app.rs` as a module with deterministic local
|
|
|
|
|
//! stubs for capture/render dependencies, then exercise `new` + `run`.
|
|
|
|
|
//! Targets: `client/src/app.rs`.
|
|
|
|
|
//! Why: app orchestration branches should stay stable in CI without physical
|
|
|
|
|
//! devices.
|
|
|
|
|
|
|
|
|
|
mod handshake {
|
2026-04-16 15:59:42 -03:00
|
|
|
#[allow(dead_code)]
|
2026-04-13 02:52:32 -03:00
|
|
|
#[derive(Default, Clone, Debug)]
|
|
|
|
|
pub struct PeerCaps {
|
|
|
|
|
pub camera: bool,
|
|
|
|
|
pub microphone: bool,
|
2026-04-16 15:59:42 -03:00
|
|
|
pub server_version: Option<String>,
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn negotiate(_uri: &str) -> PeerCaps {
|
|
|
|
|
PeerCaps {
|
|
|
|
|
camera: std::env::var("LESAVKA_TEST_CAP_CAMERA").is_ok(),
|
|
|
|
|
microphone: std::env::var("LESAVKA_TEST_CAP_MIC").is_ok(),
|
2026-04-16 15:59:42 -03:00
|
|
|
server_version: None,
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mod app_support {
|
|
|
|
|
use super::handshake::PeerCaps;
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
|
|
|
pub enum CameraCodec {
|
|
|
|
|
H264,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
|
|
|
pub struct CameraConfig {
|
|
|
|
|
pub codec: CameraCodec,
|
|
|
|
|
pub width: u32,
|
|
|
|
|
pub height: u32,
|
|
|
|
|
pub fps: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn resolve_server_addr(_args: &[String], env_addr: Option<&str>) -> String {
|
|
|
|
|
env_addr.unwrap_or("http://127.0.0.1:9").to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn camera_config_from_caps(caps: &PeerCaps) -> Option<CameraConfig> {
|
|
|
|
|
if !caps.camera {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(CameraConfig {
|
|
|
|
|
codec: CameraCodec::H264,
|
|
|
|
|
width: 1280,
|
|
|
|
|
height: 720,
|
|
|
|
|
fps: 30,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn sanitize_video_queue(queue: Option<usize>) -> usize {
|
|
|
|
|
queue.unwrap_or(64).max(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn next_delay(delay: Duration) -> Duration {
|
|
|
|
|
std::cmp::min(delay.saturating_mul(2), Duration::from_secs(8))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mod input {
|
|
|
|
|
pub mod camera {
|
|
|
|
|
use crate::app_support::CameraConfig;
|
|
|
|
|
use lesavka_common::lesavka::VideoPacket;
|
|
|
|
|
|
|
|
|
|
pub struct CameraCapture;
|
|
|
|
|
|
|
|
|
|
impl CameraCapture {
|
|
|
|
|
pub fn new(_source: Option<&str>, _cfg: Option<CameraConfig>) -> anyhow::Result<Self> {
|
|
|
|
|
Ok(Self)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn pull(&self) -> Option<VideoPacket> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub mod microphone {
|
|
|
|
|
use lesavka_common::lesavka::AudioPacket;
|
|
|
|
|
|
|
|
|
|
pub struct MicrophoneCapture;
|
|
|
|
|
|
|
|
|
|
impl MicrophoneCapture {
|
|
|
|
|
pub fn new() -> anyhow::Result<Self> {
|
|
|
|
|
Ok(Self)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn pull(&self) -> Option<AudioPacket> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub mod inputs {
|
|
|
|
|
use lesavka_common::lesavka::{KeyboardReport, MouseReport};
|
2026-04-17 04:35:41 -03:00
|
|
|
use std::sync::{Arc, atomic::AtomicBool};
|
2026-04-13 02:52:32 -03:00
|
|
|
use tokio::sync::{broadcast::Sender, mpsc::UnboundedSender};
|
|
|
|
|
|
|
|
|
|
pub struct InputAggregator {
|
|
|
|
|
_kbd_tx: Sender<KeyboardReport>,
|
|
|
|
|
_mou_tx: Sender<MouseReport>,
|
|
|
|
|
_dev_mode: bool,
|
|
|
|
|
_paste_tx: Option<UnboundedSender<String>>,
|
2026-04-17 04:35:41 -03:00
|
|
|
remote_capture_enabled: Arc<AtomicBool>,
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl InputAggregator {
|
|
|
|
|
pub fn new(
|
|
|
|
|
dev_mode: bool,
|
|
|
|
|
kbd_tx: Sender<KeyboardReport>,
|
|
|
|
|
mou_tx: Sender<MouseReport>,
|
|
|
|
|
paste_tx: Option<UnboundedSender<String>>,
|
|
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
_kbd_tx: kbd_tx,
|
|
|
|
|
_mou_tx: mou_tx,
|
|
|
|
|
_dev_mode: dev_mode,
|
|
|
|
|
_paste_tx: paste_tx,
|
2026-04-17 04:35:41 -03:00
|
|
|
remote_capture_enabled: Arc::new(AtomicBool::new(true)),
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
pub fn new_with_capture_mode(
|
|
|
|
|
dev_mode: bool,
|
|
|
|
|
kbd_tx: Sender<KeyboardReport>,
|
|
|
|
|
mou_tx: Sender<MouseReport>,
|
|
|
|
|
paste_tx: Option<UnboundedSender<String>>,
|
2026-04-17 04:35:41 -03:00
|
|
|
capture_remote_boot: bool,
|
2026-04-13 23:11:35 -03:00
|
|
|
) -> Self {
|
2026-04-17 04:35:41 -03:00
|
|
|
let aggregator = Self::new(dev_mode, kbd_tx, mou_tx, paste_tx);
|
|
|
|
|
aggregator
|
|
|
|
|
.remote_capture_enabled
|
|
|
|
|
.store(capture_remote_boot, std::sync::atomic::Ordering::Relaxed);
|
|
|
|
|
aggregator
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
pub fn init(&mut self) -> anyhow::Result<()> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn run(&mut self) -> anyhow::Result<()> {
|
|
|
|
|
std::future::pending::<()>().await;
|
|
|
|
|
#[allow(unreachable_code)]
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2026-04-17 04:35:41 -03:00
|
|
|
|
|
|
|
|
pub fn remote_capture_enabled_handle(&self) -> Arc<AtomicBool> {
|
|
|
|
|
Arc::clone(&self.remote_capture_enabled)
|
|
|
|
|
}
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mod output {
|
|
|
|
|
pub mod audio {
|
|
|
|
|
use lesavka_common::lesavka::AudioPacket;
|
|
|
|
|
|
|
|
|
|
pub struct AudioOut;
|
|
|
|
|
|
|
|
|
|
impl AudioOut {
|
|
|
|
|
pub fn new() -> anyhow::Result<Self> {
|
|
|
|
|
Ok(Self)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn push(&self, _pkt: AudioPacket) {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub mod video {
|
|
|
|
|
use lesavka_common::lesavka::VideoPacket;
|
|
|
|
|
|
|
|
|
|
pub struct MonitorWindow;
|
2026-04-14 02:34:14 -03:00
|
|
|
pub struct UnifiedMonitorWindow;
|
2026-04-13 02:52:32 -03:00
|
|
|
|
|
|
|
|
impl MonitorWindow {
|
|
|
|
|
pub fn new(_id: u32) -> anyhow::Result<Self> {
|
|
|
|
|
Ok(Self)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn push_packet(&self, _pkt: VideoPacket) {}
|
|
|
|
|
}
|
2026-04-14 02:34:14 -03:00
|
|
|
|
|
|
|
|
impl UnifiedMonitorWindow {
|
|
|
|
|
pub fn new() -> anyhow::Result<Self> {
|
|
|
|
|
Ok(Self)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn push_packet(&self, _pkt: VideoPacket) {}
|
|
|
|
|
}
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mod paste {
|
|
|
|
|
use anyhow::bail;
|
|
|
|
|
use lesavka_common::lesavka::PasteRequest;
|
|
|
|
|
|
|
|
|
|
pub fn build_paste_request(text: &str) -> anyhow::Result<PasteRequest> {
|
|
|
|
|
if text == "bad" {
|
|
|
|
|
bail!("synthetic paste build failure");
|
|
|
|
|
}
|
|
|
|
|
Ok(PasteRequest {
|
|
|
|
|
nonce: vec![],
|
|
|
|
|
data: text.as_bytes().to_vec(),
|
|
|
|
|
encrypted: true,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[path = "../../client/src/app.rs"]
|
|
|
|
|
#[allow(warnings)]
|
|
|
|
|
mod app_include_contract;
|
|
|
|
|
|
|
|
|
|
mod tests {
|
2026-04-16 12:58:05 -03:00
|
|
|
use super::app_include_contract::{
|
|
|
|
|
LesavkaClientApp, keyboard_stream_report, mouse_stream_report,
|
|
|
|
|
};
|
|
|
|
|
use lesavka_common::lesavka::{KeyboardReport, MouseReport};
|
2026-04-13 02:52:32 -03:00
|
|
|
use serial_test::serial;
|
|
|
|
|
use temp_env::with_var;
|
2026-04-16 12:58:05 -03:00
|
|
|
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
2026-04-13 02:52:32 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn run_headless_reaches_pending_reactor_branch() {
|
|
|
|
|
with_var("LESAVKA_HEADLESS", Some("1"), || {
|
|
|
|
|
with_var("LESAVKA_SERVER_ADDR", Some("http://127.0.0.1:9"), || {
|
|
|
|
|
with_var("LESAVKA_TEST_CAP_CAMERA", None::<&str>, || {
|
|
|
|
|
with_var("LESAVKA_TEST_CAP_MIC", None::<&str>, || {
|
|
|
|
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
|
|
|
rt.block_on(async {
|
|
|
|
|
let mut app = LesavkaClientApp::new().expect("new app");
|
|
|
|
|
let result = tokio::time::timeout(
|
|
|
|
|
std::time::Duration::from_millis(80),
|
|
|
|
|
app.run(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.is_err(), "headless run should stay pending");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn run_non_headless_starts_stream_tasks_with_stubbed_caps() {
|
|
|
|
|
with_var("LESAVKA_HEADLESS", None::<&str>, || {
|
|
|
|
|
with_var("LESAVKA_SERVER_ADDR", Some("http://127.0.0.1:9"), || {
|
|
|
|
|
with_var("LESAVKA_TEST_CAP_CAMERA", Some("1"), || {
|
|
|
|
|
with_var("LESAVKA_TEST_CAP_MIC", Some("1"), || {
|
|
|
|
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
|
|
|
rt.block_on(async {
|
|
|
|
|
let mut app = LesavkaClientApp::new().expect("new app");
|
|
|
|
|
let result = tokio::time::timeout(
|
|
|
|
|
std::time::Duration::from_millis(120),
|
|
|
|
|
app.run(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(
|
|
|
|
|
result.is_err(),
|
|
|
|
|
"run should stay in the central reactor loop"
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-16 12:58:05 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn keyboard_stream_report_turns_lag_into_a_clean_reset() {
|
2026-04-17 04:35:41 -03:00
|
|
|
let mut remote_capture_was_enabled = true;
|
|
|
|
|
let pkt = keyboard_stream_report(
|
|
|
|
|
Err(BroadcastStreamRecvError::Lagged(3)),
|
|
|
|
|
true,
|
|
|
|
|
&mut remote_capture_was_enabled,
|
|
|
|
|
)
|
|
|
|
|
.expect("lagged keyboard item should produce reset");
|
2026-04-16 12:58:05 -03:00
|
|
|
assert_eq!(pkt.data, vec![0; 8]);
|
|
|
|
|
|
2026-04-17 04:35:41 -03:00
|
|
|
let passthrough = keyboard_stream_report(
|
|
|
|
|
Ok(KeyboardReport {
|
|
|
|
|
data: vec![1, 2, 3],
|
|
|
|
|
}),
|
|
|
|
|
true,
|
|
|
|
|
&mut remote_capture_was_enabled,
|
|
|
|
|
)
|
2026-04-16 12:58:05 -03:00
|
|
|
.expect("ok keyboard item should pass through");
|
|
|
|
|
assert_eq!(passthrough.data, vec![1, 2, 3]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn mouse_stream_report_turns_lag_into_a_neutral_packet() {
|
2026-04-17 04:35:41 -03:00
|
|
|
let mut remote_capture_was_enabled = true;
|
|
|
|
|
let pkt = mouse_stream_report(
|
|
|
|
|
Err(BroadcastStreamRecvError::Lagged(5)),
|
|
|
|
|
true,
|
|
|
|
|
&mut remote_capture_was_enabled,
|
|
|
|
|
)
|
|
|
|
|
.expect("lagged mouse item should produce neutral packet");
|
2026-04-16 12:58:05 -03:00
|
|
|
assert_eq!(pkt.data, vec![0; 4]);
|
|
|
|
|
|
2026-04-17 04:35:41 -03:00
|
|
|
let passthrough = mouse_stream_report(
|
|
|
|
|
Ok(MouseReport {
|
|
|
|
|
data: vec![9, 8, 7, 6],
|
|
|
|
|
}),
|
|
|
|
|
true,
|
|
|
|
|
&mut remote_capture_was_enabled,
|
|
|
|
|
)
|
2026-04-16 12:58:05 -03:00
|
|
|
.expect("ok mouse item should pass through");
|
|
|
|
|
assert_eq!(passthrough.data, vec![9, 8, 7, 6]);
|
|
|
|
|
}
|
2026-04-17 04:35:41 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn keyboard_stream_report_blocks_stale_packets_after_local_handoff() {
|
|
|
|
|
let mut remote_capture_was_enabled = true;
|
|
|
|
|
let reset = keyboard_stream_report(
|
|
|
|
|
Ok(KeyboardReport {
|
|
|
|
|
data: vec![4, 0, 5, 0, 0, 0, 0, 0],
|
|
|
|
|
}),
|
|
|
|
|
false,
|
|
|
|
|
&mut remote_capture_was_enabled,
|
|
|
|
|
)
|
|
|
|
|
.expect("switching local should emit one reset");
|
|
|
|
|
assert_eq!(reset.data, vec![0; 8]);
|
|
|
|
|
|
|
|
|
|
let dropped = keyboard_stream_report(
|
|
|
|
|
Ok(KeyboardReport {
|
|
|
|
|
data: vec![1, 2, 3],
|
|
|
|
|
}),
|
|
|
|
|
false,
|
|
|
|
|
&mut remote_capture_was_enabled,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
dropped.is_none(),
|
|
|
|
|
"stale keyboard packets should be dropped locally"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn mouse_stream_report_blocks_stale_packets_after_local_handoff() {
|
|
|
|
|
let mut remote_capture_was_enabled = true;
|
|
|
|
|
let reset = mouse_stream_report(
|
|
|
|
|
Ok(MouseReport {
|
|
|
|
|
data: vec![9, 8, 7, 6],
|
|
|
|
|
}),
|
|
|
|
|
false,
|
|
|
|
|
&mut remote_capture_was_enabled,
|
|
|
|
|
)
|
|
|
|
|
.expect("switching local should emit one neutral mouse packet");
|
|
|
|
|
assert_eq!(reset.data, vec![0; 4]);
|
|
|
|
|
|
|
|
|
|
let dropped = mouse_stream_report(
|
|
|
|
|
Ok(MouseReport {
|
|
|
|
|
data: vec![1, 1, 1, 1],
|
|
|
|
|
}),
|
|
|
|
|
false,
|
|
|
|
|
&mut remote_capture_was_enabled,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
dropped.is_none(),
|
|
|
|
|
"stale mouse packets should be dropped locally"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|