From eaa03924ed0679f4dd6a3615602858a80b2536a3 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 8 Apr 2026 20:00:14 -0300 Subject: [PATCH] master: ship webcam hdmi path + secure paste + deploy tooling --- .dockerignore | 7 ++ Jenkinsfile | 89 +++++++++++++ client/Cargo.toml | 2 + client/src/app.rs | 53 +++++++- client/src/input/camera.rs | 81 ++++++++---- client/src/input/inputs.rs | 127 ++++++++++++++++--- client/src/input/keyboard.rs | 201 +++++++++++++++++++++++++++++- client/src/input/keymap.rs | 48 +++++++ client/src/input/microphone.rs | 6 + client/src/input/mouse.rs | 144 ++++++++++++++++++++- client/src/lib.rs | 1 + client/src/output/layout.rs | 50 +++----- client/src/output/video.rs | 10 +- client/src/paste.rs | 73 +++++++++++ common/proto/lesavka.proto | 8 ++ common/src/bin/cli.rs | 2 +- docker/client.Dockerfile | 49 ++++++++ docker/server.Dockerfile | 38 ++++++ scripts/ansible/deploy-client.yml | 32 +++++ scripts/ansible/deploy-server.yml | 32 +++++ scripts/ci/build-dist.sh | 31 +++++ scripts/ci/build-images.sh | 63 ++++++++++ scripts/daemon/lesavka-uvc.sh | 6 + scripts/install/client.sh | 46 +++++-- scripts/install/server.sh | 1 + server/Cargo.toml | 2 + server/src/audio.rs | 48 +++++-- server/src/bin/lesavka-uvc.rs | 24 ++-- server/src/camera.rs | 13 +- server/src/gadget.rs | 5 +- server/src/handshake.rs | 2 +- server/src/lib.rs | 1 + server/src/main.rs | 20 ++- server/src/paste.rs | 132 ++++++++++++++++++++ server/src/video.rs | 1 - 35 files changed, 1321 insertions(+), 127 deletions(-) create mode 100644 .dockerignore create mode 100644 Jenkinsfile create mode 100644 client/src/paste.rs create mode 100644 docker/client.Dockerfile create mode 100644 docker/server.Dockerfile create mode 100644 scripts/ansible/deploy-client.yml create mode 100644 scripts/ansible/deploy-server.yml create mode 100755 scripts/ci/build-dist.sh create mode 100755 scripts/ci/build-images.sh create mode 100644 server/src/paste.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..da79076 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +**/target +**/dist +**/.idea +**/.vscode +**/.cache +**/.DS_Store diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..5f947e2 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,89 @@ +pipeline { + agent any + + options { + timestamps() + disableConcurrentBuilds() + } + + parameters { + booleanParam(name: 'RUN_TESTS', defaultValue: false, description: 'Run cargo tests') + booleanParam(name: 'PUSH_IMAGES', defaultValue: true, description: 'Push images to registry') + string(name: 'REGISTRY_CREDENTIALS_ID', defaultValue: 'registry-bstein-dev', description: 'Jenkins credentials id for registry.bstein.dev') + } + + environment { + REGISTRY = 'registry.bstein.dev' + IMAGE_PREFIX = "${REGISTRY}/lesavka" + CARGO_TERM_COLOR = 'always' + DOCKER_BUILDKIT = '1' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Format') { + steps { + sh 'cargo fmt --all --manifest-path common/Cargo.toml -- --check' + sh 'cargo fmt --all --manifest-path server/Cargo.toml -- --check' + sh 'cargo fmt --all --manifest-path client/Cargo.toml -- --check' + } + } + + stage('Clippy') { + steps { + sh 'cargo clippy --all-targets --manifest-path server/Cargo.toml -D warnings' + sh 'cargo clippy --all-targets --manifest-path client/Cargo.toml -D warnings' + } + } + + stage('Build Dist') { + steps { + sh 'scripts/ci/build-dist.sh' + } + } + + stage('Tests') { + when { + expression { return params.RUN_TESTS } + } + steps { + sh 'cargo test --manifest-path server/Cargo.toml' + sh 'cargo test --manifest-path client/Cargo.toml' + } + } + + stage('Docker Login') { + when { + expression { return params.PUSH_IMAGES } + } + steps { + withCredentials([ + usernamePassword( + credentialsId: params.REGISTRY_CREDENTIALS_ID, + usernameVariable: 'REGISTRY_USER', + passwordVariable: 'REGISTRY_PASS' + ) + ]) { + sh 'echo "$REGISTRY_PASS" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin' + } + } + } + + stage('Build Images') { + steps { + sh 'PUSH_IMAGES=${PUSH_IMAGES} scripts/ci/build-images.sh' + } + } + } + + post { + always { + archiveArtifacts artifacts: 'dist/*.tar.gz', fingerprint: true, allowEmptyArchive: true + } + } +} diff --git a/client/Cargo.toml b/client/Cargo.toml index 2fef19e..e6e4fb7 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -30,6 +30,8 @@ serde_json = "1.0" async-stream = "0.3" shell-escape = "0.1" v4l = "0.14" +chacha20poly1305 = "0.10" +base64 = "0.22" [build-dependencies] prost-build = "0.13" diff --git a/client/src/app.rs b/client/src/app.rs index b465f98..d84ae9b 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -4,7 +4,7 @@ use anyhow::Result; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, mpsc}; use tokio_stream::{StreamExt, wrappers::BroadcastStream}; use tonic::{Request, transport::Channel}; use tracing::{debug, error, info, trace, warn}; @@ -26,6 +26,7 @@ use crate::{ input::microphone::MicrophoneCapture, output::audio::AudioOut, output::video::MonitorWindow, + paste, }; pub struct LesavkaClientApp { @@ -35,6 +36,7 @@ pub struct LesavkaClientApp { headless: bool, kbd_tx: broadcast::Sender, mou_tx: broadcast::Sender, + paste_rx: Option>, } impl LesavkaClientApp { @@ -48,11 +50,17 @@ impl LesavkaClientApp { let (kbd_tx, _) = broadcast::channel(1024); let (mou_tx, _) = broadcast::channel(4096); + let (paste_tx, paste_rx) = mpsc::unbounded_channel(); let agg = if headless { None } else { - Some(InputAggregator::new(dev_mode, kbd_tx.clone(), mou_tx.clone())) + Some(InputAggregator::new( + dev_mode, + kbd_tx.clone(), + mou_tx.clone(), + Some(paste_tx), + )) }; Ok(Self { @@ -62,6 +70,7 @@ impl LesavkaClientApp { headless, kbd_tx, mou_tx, + paste_rx: Some(paste_rx), }) } @@ -108,6 +117,8 @@ impl LesavkaClientApp { let mut agg_task = None; let mut kbd_loop = None; let mut mou_loop = None; + let mut paste_task = None; + let paste_rx = self.paste_rx.take(); if !self.headless { /*────────── input aggregator task (grab after handshake) ─────────────*/ let mut aggregator = self.aggregator.take().expect("InputAggregator present"); @@ -121,6 +132,9 @@ impl LesavkaClientApp { /*────────── HID streams (never return) ────────*/ kbd_loop = Some(self.stream_loop_keyboard(hid_ep.clone())); mou_loop = Some(self.stream_loop_mouse(hid_ep.clone())); + if let Some(rx) = paste_rx { + paste_task = Some(Self::paste_loop(hid_ep.clone(), rx)); + } } else { info!("🧪 headless mode: skipping HID input capture"); } @@ -222,9 +236,11 @@ impl LesavkaClientApp { let kbd_loop = kbd_loop.expect("kbd_loop"); let mou_loop = mou_loop.expect("mou_loop"); let agg_task = agg_task.expect("agg_task"); + let paste_task = paste_task.expect("paste_task"); tokio::select! { _ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); }, _ = mou_loop => { warn!("⚠️🖱️ mouse stream finished"); }, + _ = paste_task => { warn!("⚠️📋 paste loop finished"); }, _ = suicide => { /* handled above */ }, r = agg_task => { match r { @@ -232,7 +248,7 @@ impl LesavkaClientApp { Ok(Err(e)) => error!("input aggregator error: {e:?}"), Err(join_err) => error!("aggregator task panicked: {join_err:?}"), } - std::process::exit(1); + return Ok(()); } } } @@ -243,6 +259,37 @@ impl LesavkaClientApp { Ok(()) } + /*──────────────── paste loop ───────────────*/ + fn paste_loop( + ep: Channel, + mut rx: mpsc::UnboundedReceiver, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut cli = RelayClient::new(ep.clone()); + while let Some(text) = rx.recv().await { + match paste::build_paste_request(&text) { + Ok(req) => match cli.paste_text(Request::new(req)).await { + Ok(resp) => { + let reply = resp.get_ref(); + if !reply.ok { + warn!("📋 paste rejected: {}", reply.error); + } else { + debug!("📋 paste delivered"); + } + } + Err(e) => { + warn!("📋 paste failed: {e}"); + cli = RelayClient::new(ep.clone()); + } + }, + Err(e) => { + warn!("📋 paste build failed: {e}"); + } + } + } + }) + } + /*──────────────── keyboard stream ───────────────*/ async fn stream_loop_keyboard(&self, ep: Channel) { loop { diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 3ac1381..e45fa5c 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -35,24 +35,44 @@ pub struct CameraCapture { } impl CameraCapture { - pub fn new( - device_fragment: Option<&str>, - cfg: Option, - ) -> anyhow::Result { + pub fn new(device_fragment: Option<&str>, cfg: Option) -> anyhow::Result { gst::init().ok(); - // Pick device (prefers V4L2 nodes with capture capability) - let dev = match device_fragment { - Some(path) if path.starts_with("/dev/") => path.to_string(), - Some(fragment) => Self::find_device(fragment).unwrap_or_else(|| "/dev/video0".into()), - None => "/dev/video0".into(), + // Select source: V4L2 device or test pattern + let (src_desc, dev_label, allow_mjpg_source) = match device_fragment { + Some(fragment) + if fragment.eq_ignore_ascii_case("test") + || fragment.eq_ignore_ascii_case("videotestsrc") => + { + let pattern = + std::env::var("LESAVKA_CAM_TEST_PATTERN").unwrap_or_else(|_| "smpte".into()); + ( + format!("videotestsrc is-live=true pattern={pattern}"), + format!("videotestsrc:{pattern}"), + false, + ) + } + Some(path) if path.starts_with("/dev/") => ( + format!("v4l2src device={path} do-timestamp=true"), + path.to_string(), + true, + ), + Some(fragment) => { + let dev = Self::find_device(fragment).unwrap_or_else(|| "/dev/video0".into()); + (format!("v4l2src device={dev} do-timestamp=true"), dev, true) + } + None => { + let dev = "/dev/video0".to_string(); + (format!("v4l2src device={dev} do-timestamp=true"), dev, true) + } }; - let use_mjpg_source = std::env::var("LESAVKA_CAM_MJPG").is_ok() - || std::env::var("LESAVKA_CAM_FORMAT") - .ok() - .map(|v| matches!(v.to_ascii_lowercase().as_str(), "mjpg" | "mjpeg" | "jpeg")) - .unwrap_or(false); + let use_mjpg_source = allow_mjpg_source + && (std::env::var("LESAVKA_CAM_MJPG").is_ok() + || std::env::var("LESAVKA_CAM_FORMAT") + .ok() + .map(|v| matches!(v.to_ascii_lowercase().as_str(), "mjpg" | "mjpeg" | "jpeg")) + .unwrap_or(false)); let output_mjpeg = cfg .map(|cfg| matches!(cfg.codec, CameraCodec::Mjpeg)) .unwrap_or_else(|| { @@ -71,15 +91,20 @@ impl CameraCapture { tracing::info!("📸 using MJPG source with software encode"); } if output_mjpeg { - tracing::info!( - "📸 outputting MJPEG frames for UVC (quality={jpeg_quality})" - ); + tracing::info!("📸 outputting MJPEG frames for UVC (quality={jpeg_quality})"); } else { tracing::info!("📸 using encoder element: {enc}"); } - let width = cfg.map(|cfg| cfg.width).unwrap_or_else(|| env_u32("LESAVKA_CAM_WIDTH", 1280)); - let height = cfg.map(|cfg| cfg.height).unwrap_or_else(|| env_u32("LESAVKA_CAM_HEIGHT", 720)); - let fps = cfg.map(|cfg| cfg.fps).unwrap_or_else(|| env_u32("LESAVKA_CAM_FPS", 25)).max(1); + let width = cfg + .map(|cfg| cfg.width) + .unwrap_or_else(|| env_u32("LESAVKA_CAM_WIDTH", 1280)); + let height = cfg + .map(|cfg| cfg.height) + .unwrap_or_else(|| env_u32("LESAVKA_CAM_HEIGHT", 720)); + let fps = cfg + .map(|cfg| cfg.fps) + .unwrap_or_else(|| env_u32("LESAVKA_CAM_FPS", 25)) + .max(1); let have_nvvidconv = gst::ElementFactory::find("nvvidconv").is_some(); let (src_caps, preenc) = match enc { // ─────────────────────────────────────────────────────────────────── @@ -117,14 +142,14 @@ impl CameraCapture { let desc = if output_mjpeg { if use_mjpg_source { format!( - "v4l2src device={dev} do-timestamp=true ! \ + "{src_desc} ! \ image/jpeg,width={width},height={height},framerate={fps}/1 ! \ queue max-size-buffers=30 leaky=downstream ! \ appsink name=asink emit-signals=true max-buffers=60 drop=true" ) } else { format!( - "v4l2src device={dev} do-timestamp=true ! \ + "{src_desc} ! \ video/x-raw,width={width},height={height},framerate={fps}/1 ! \ videoconvert ! jpegenc quality={jpeg_quality} ! \ queue max-size-buffers=30 leaky=downstream ! \ @@ -133,7 +158,7 @@ impl CameraCapture { } } else if use_mjpg_source { format!( - "v4l2src device={dev} do-timestamp=true ! \ + "{src_desc} ! \ image/jpeg,width={width},height={height} ! \ jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \ videoconvert ! {enc} {kf_prop}={kf_val} ! \ @@ -143,7 +168,7 @@ impl CameraCapture { ) } else { format!( - "v4l2src device={dev} do-timestamp=true ! {src_caps} ! \ + "{src_desc} ! {src_caps} ! \ {preenc} {enc} {kf_prop}={kf_val} ! \ h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ queue max-size-buffers=30 leaky=downstream ! \ @@ -165,7 +190,7 @@ impl CameraCapture { .expect("appsink down‑cast"); pipeline.set_state(gst::State::Playing)?; - tracing::info!("📸 webcam pipeline ▶️ device={dev}"); + tracing::info!("📸 webcam pipeline ▶️ device={dev_label}"); Ok(Self { pipeline, sink }) } @@ -270,3 +295,9 @@ impl CameraCapture { } } } + +impl Drop for CameraCapture { + fn drop(&mut self) { + let _ = self.pipeline.set_state(gst::State::Null); + } +} diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index 5d32329..2cb1123 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -1,7 +1,8 @@ // client/src/input/inputs.rs use anyhow::{Context, Result, bail}; -use evdev::{Device, EventType, KeyCode, RelativeAxisCode}; +use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode}; +use std::collections::HashSet; use tokio::{ sync::broadcast::Sender, time::{Duration, interval}, @@ -12,6 +13,7 @@ use lesavka_common::lesavka::{KeyboardReport, MouseReport}; use super::{keyboard::KeyboardAggregator, mouse::MouseAggregator}; use crate::layout::{Layout, apply as apply_layout}; +use tokio::sync::mpsc::UnboundedSender; pub struct InputAggregator { kbd_tx: Sender, @@ -19,6 +21,10 @@ pub struct InputAggregator { dev_mode: bool, released: bool, magic_active: bool, + pending_release: bool, + pending_kill: bool, + pending_keys: HashSet, + paste_tx: Option>, keyboards: Vec, mice: Vec, } @@ -28,6 +34,7 @@ impl InputAggregator { dev_mode: bool, kbd_tx: Sender, mou_tx: Sender, + paste_tx: Option>, ) -> Self { Self { kbd_tx, @@ -35,6 +42,10 @@ impl InputAggregator { dev_mode, released: false, magic_active: false, + pending_release: false, + pending_kill: false, + pending_keys: HashSet::new(), + paste_tx, keyboards: Vec::new(), mice: Vec::new(), } @@ -83,7 +94,12 @@ impl InputAggregator { // pass dev_mode to aggregator // let kbd_agg = KeyboardAggregator::new(dev, self.dev_mode); - let kbd_agg = KeyboardAggregator::new(dev, self.dev_mode, self.kbd_tx.clone()); + let kbd_agg = KeyboardAggregator::new( + dev, + self.dev_mode, + self.kbd_tx.clone(), + self.paste_tx.clone(), + ); self.keyboards.push(kbd_agg); found_any = true; continue; @@ -123,14 +139,14 @@ impl InputAggregator { let mut tick = interval(Duration::from_millis(10)); let mut current = Layout::SideBySide; loop { - let magic_now = self.keyboards.iter().any(|k| k.magic_grab()); - let magic_left = self.keyboards.iter().any(|k| k.magic_left()); - let magic_right = self.keyboards.iter().any(|k| k.magic_right()); let mut want_kill = false; for kbd in &mut self.keyboards { kbd.process_events(); want_kill |= kbd.magic_kill(); } + let magic_now = self.keyboards.iter().any(|k| k.magic_grab()); + let magic_left = self.keyboards.iter().any(|k| k.magic_left()); + let magic_right = self.keyboards.iter().any(|k| k.magic_right()); if magic_now && !self.magic_active { self.toggle_grab(); @@ -143,9 +159,47 @@ impl InputAggregator { }; apply_layout(current); } - if want_kill { + if want_kill && !self.pending_kill { warn!("🧙 magic chord - killing 🪄 AVADA KEDAVRA!!! 💥💀⚰️"); - std::process::exit(0); + for k in &mut self.keyboards { + k.send_empty_report(); + k.set_send(false); + } + for m in &mut self.mice { + m.reset_state(); + m.set_send(false); + } + self.pending_kill = true; + self.capture_pending_keys(); + } + + if self.pending_release || self.pending_kill { + let chord_released = if self.pending_keys.is_empty() { + !self + .keyboards + .iter() + .any(|k| k.magic_grab() || k.magic_kill()) + } else { + self.pending_keys + .iter() + .all(|key| !self.keyboards.iter().any(|k| k.has_key(*key))) + }; + if chord_released { + for k in &mut self.keyboards { + k.set_grab(false); + k.reset_state(); + } + for m in &mut self.mice { + m.set_grab(false); + m.reset_state(); + } + self.released = true; + if self.pending_kill { + return Ok(()); + } + self.pending_release = false; + self.pending_keys.clear(); + } } for mouse in &mut self.mice { @@ -158,20 +212,50 @@ impl InputAggregator { } fn toggle_grab(&mut self) { + if self.pending_release || self.pending_kill { + return; + } if self.released { tracing::info!("🧙 magic chord - restricting devices 🪄 IMPERIUS!!! 🎮🔒"); } else { tracing::info!("🧙 magic chord - freeing devices 🪄 EXPELLIARMUS!!! 🔓🕊️"); } - for k in &mut self.keyboards { - k.set_grab(self.released); - k.set_send(self.released); + if self.released { + // switching to remote control + for k in &mut self.keyboards { + k.reset_state(); + k.set_send(true); + k.set_grab(true); + } + for m in &mut self.mice { + m.reset_state(); + m.set_send(true); + m.set_grab(true); + } + self.released = false; + self.pending_release = false; + } else { + // switching to local control: stop sending, keep grab until chord released + for k in &mut self.keyboards { + k.send_empty_report(); + k.set_send(false); + } + for m in &mut self.mice { + m.reset_state(); + m.set_send(false); + } + self.pending_release = true; + self.capture_pending_keys(); } - for m in &mut self.mice { - m.set_grab(self.released); - m.set_send(self.released); + } + + fn capture_pending_keys(&mut self) { + self.pending_keys.clear(); + for k in &self.keyboards { + for key in k.pressed_keys_snapshot() { + self.pending_keys.insert(key); + } } - self.released = !self.released; } } @@ -188,7 +272,7 @@ fn classify_device(dev: &Device) -> DeviceKind { } } - // Mouse logic + // Mouse logic (relative) if evbits.contains(EventType::RELATIVE) { if let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), dev.supported_keys()) { let has_xy = @@ -199,6 +283,19 @@ fn classify_device(dev: &Device) -> DeviceKind { } } } + // Touchpad logic (absolute) + if evbits.contains(EventType::ABSOLUTE) { + if let (Some(abs), Some(keys)) = (dev.supported_absolute_axes(), dev.supported_keys()) { + let has_xy = (abs.contains(AbsoluteAxisCode::ABS_X) + && abs.contains(AbsoluteAxisCode::ABS_Y)) + || (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X) + && abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y)); + let has_btn = keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT); + if has_xy && has_btn { + return DeviceKind::Mouse; + } + } + } DeviceKind::Other } diff --git a/client/src/input/keyboard.rs b/client/src/input/keyboard.rs index a203789..cc456aa 100644 --- a/client/src/input/keyboard.rs +++ b/client/src/input/keyboard.rs @@ -3,35 +3,53 @@ use evdev::{Device, EventType, InputEvent, KeyCode}; use std::{ collections::HashSet, - sync::atomic::{AtomicU32, Ordering}, + sync::atomic::{AtomicU32, AtomicU64, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use tokio::sync::broadcast::Sender; +use tokio::sync::mpsc::UnboundedSender; use tracing::{debug, error, trace}; use lesavka_common::lesavka::KeyboardReport; -use super::keymap::{is_modifier, keycode_to_usage}; +use super::keymap::{char_to_usage, is_modifier, keycode_to_usage}; pub struct KeyboardAggregator { dev: Device, tx: Sender, dev_mode: bool, sending_disabled: bool, + paste_enabled: bool, + paste_rpc_enabled: bool, + paste_tx: Option>, pressed_keys: HashSet, } /*───────── helpers ───────────────────────────────────────────────────*/ /// Monotonically-increasing ID that can be logged on server & client. static SEQ: AtomicU32 = AtomicU32::new(0); +static LAST_PASTE_MS: AtomicU64 = AtomicU64::new(0); impl KeyboardAggregator { - pub fn new(dev: Device, dev_mode: bool, tx: Sender) -> Self { + pub fn new( + dev: Device, + dev_mode: bool, + tx: Sender, + paste_tx: Option>, + ) -> Self { let _ = dev.set_nonblocking(true); Self { dev, tx, dev_mode, sending_disabled: false, + paste_enabled: std::env::var("LESAVKA_CLIPBOARD_PASTE") + .map(|v| v != "0") + .unwrap_or(true), + paste_rpc_enabled: std::env::var("LESAVKA_PASTE_RPC") + .map(|v| v != "0") + .unwrap_or(true), + paste_tx, pressed_keys: HashSet::new(), } } @@ -48,6 +66,10 @@ impl KeyboardAggregator { self.sending_disabled = !send; } + pub fn send_empty_report(&self) { + self.send_report([0; 8]); + } + pub fn process_events(&mut self) { // --- first fetch, then log (avoids aliasing borrow) --- let events: Vec = match self.dev.fetch_events() { @@ -74,6 +96,29 @@ impl KeyboardAggregator { continue; } let code = KeyCode::new(ev.code()); + + if self.paste_enabled + && ev.value() == 1 + && code == KeyCode::KEY_V + && self.paste_chord_active() + { + if !self.paste_debounced() { + continue; + } + // swallow Ctrl+V and inject clipboard text instead + self.pressed_keys.remove(&KeyCode::KEY_V); + self.pressed_keys.remove(&KeyCode::KEY_LEFTCTRL); + self.pressed_keys.remove(&KeyCode::KEY_RIGHTCTRL); + self.pressed_keys.remove(&KeyCode::KEY_LEFTALT); + self.pressed_keys.remove(&KeyCode::KEY_RIGHTALT); + self.send_empty_report(); + if self.paste_rpc_enabled && self.paste_via_rpc() { + continue; + } + self.paste_clipboard(); + continue; + } + match ev.value() { 1 => { self.pressed_keys.insert(code); @@ -122,6 +167,10 @@ impl KeyboardAggregator { self.pressed_keys.contains(&kc) } + pub fn pressed_keys_snapshot(&self) -> Vec { + self.pressed_keys.iter().copied().collect() + } + pub fn magic_grab(&self) -> bool { self.has_key(KeyCode::KEY_LEFTCTRL) && self.has_key(KeyCode::KEY_LEFTSHIFT) @@ -143,6 +192,152 @@ impl KeyboardAggregator { pub fn magic_kill(&self) -> bool { self.has_key(KeyCode::KEY_LEFTCTRL) && self.has_key(KeyCode::KEY_ESC) } + + pub fn reset_state(&mut self) { + if self.pressed_keys.is_empty() { + self.send_empty_report(); + return; + } + self.pressed_keys.clear(); + self.send_empty_report(); + } + + fn send_report(&self, report: [u8; 8]) { + if self.sending_disabled { + return; + } + let _ = self.tx.send(KeyboardReport { + data: report.to_vec(), + }); + } + + fn paste_chord_active(&self) -> bool { + let chord = std::env::var("LESAVKA_CLIPBOARD_CHORD") + .unwrap_or_else(|_| "ctrl+alt+v".into()) + .to_ascii_lowercase(); + let have_ctrl = self.has_key(KeyCode::KEY_LEFTCTRL) || self.has_key(KeyCode::KEY_RIGHTCTRL); + let have_alt = self.has_key(KeyCode::KEY_LEFTALT) || self.has_key(KeyCode::KEY_RIGHTALT); + match chord.as_str() { + "ctrl+v" => have_ctrl, + "ctrl+alt+v" => have_ctrl && have_alt, + _ => have_ctrl && have_alt, + } + } + + fn paste_debounced(&self) -> bool { + let debounce_ms = std::env::var("LESAVKA_CLIPBOARD_DEBOUNCE_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(500); + if debounce_ms == 0 { + return true; + } + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let last = LAST_PASTE_MS.load(Ordering::Relaxed); + if now_ms.saturating_sub(last) < debounce_ms { + tracing::debug!("📋 paste ignored (debounce)"); + return false; + } + LAST_PASTE_MS.store(now_ms, Ordering::Relaxed); + true + } + + fn paste_clipboard(&self) { + let text = match read_clipboard_text() { + Some(t) if !t.is_empty() => t, + Some(_) => { + tracing::warn!("📋 clipboard empty"); + return; + } + None => { + tracing::warn!("📋 clipboard read failed"); + return; + } + }; + let max = std::env::var("LESAVKA_CLIPBOARD_MAX") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(4096); + let delay_ms = std::env::var("LESAVKA_CLIPBOARD_DELAY_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + let delay = Duration::from_millis(delay_ms); + + tracing::info!("📋 pasting {} chars", text.chars().count().min(max)); + + for c in text.chars().take(max) { + if let Some((usage, mods)) = char_to_usage(c) { + self.send_report([mods, 0, usage, 0, 0, 0, 0, 0]); + self.send_report([0; 8]); + if delay_ms > 0 { + std::thread::sleep(delay); + } + } + } + } + + fn paste_via_rpc(&self) -> bool { + let Some(tx) = self.paste_tx.as_ref() else { + return false; + }; + let text = match read_clipboard_text() { + Some(t) if !t.is_empty() => t, + Some(_) => { + tracing::warn!("📋 clipboard empty"); + return true; + } + None => { + tracing::warn!("📋 clipboard read failed"); + return true; + } + }; + tx.send(text).is_ok() + } +} + +fn read_clipboard_text() -> Option { + if let Ok(cmd) = std::env::var("LESAVKA_CLIPBOARD_CMD") { + if let Ok(out) = std::process::Command::new("sh") + .arg("-lc") + .arg(cmd.clone()) + .output() + { + if out.status.success() { + let text = String::from_utf8_lossy(&out.stdout).to_string(); + if !text.is_empty() { + return Some(text); + } + tracing::warn!("📋 clipboard command returned empty"); + } else { + let err = String::from_utf8_lossy(&out.stderr); + tracing::warn!("📋 clipboard command failed: {cmd} ({err})"); + } + } else { + tracing::warn!("📋 clipboard command failed to spawn: {cmd}"); + } + // fall through to auto-detect if custom command fails + } + + let candidates: &[(&str, &[&str])] = &[ + ("wl-paste", &["--no-newline", "--type", "text/plain"]), + ("wl-paste", &["--no-newline"]), + ("wl-paste", &[]), + ("xclip", &["-selection", "clipboard", "-o"]), + ("xsel", &["-b", "-o"]), + ]; + + for (cmd, args) in candidates { + if let Ok(out) = std::process::Command::new(cmd).args(*args).output() { + if out.status.success() { + return Some(String::from_utf8_lossy(&out.stdout).to_string()); + } + } + } + None } impl Drop for KeyboardAggregator { diff --git a/client/src/input/keymap.rs b/client/src/input/keymap.rs index b4d3192..6319570 100644 --- a/client/src/input/keymap.rs +++ b/client/src/input/keymap.rs @@ -136,3 +136,51 @@ pub fn is_modifier(key: KeyCode) -> Option { _ => None, } } + +/// Map a printable character to (usage, modifiers). +/// Modifiers currently only include Shift for uppercase/punctuation. +pub fn char_to_usage(c: char) -> Option<(u8, u8)> { + let shift = 0x02; // left shift in HID modifier byte + match c { + 'a'..='z' => Some((0x04 + (c as u8 - b'a'), 0)), + 'A'..='Z' => Some((0x04 + (c as u8 - b'A'), shift)), + '1'..='9' => Some((0x1E + (c as u8 - b'1'), 0)), + '0' => Some((0x27, 0)), + '!' => Some((0x1E, shift)), + '@' => Some((0x1F, shift)), + '#' => Some((0x20, shift)), + '$' => Some((0x21, shift)), + '%' => Some((0x22, shift)), + '^' => Some((0x23, shift)), + '&' => Some((0x24, shift)), + '*' => Some((0x25, shift)), + '(' => Some((0x26, shift)), + ')' => Some((0x27, shift)), + '-' => Some((0x2D, 0)), + '_' => Some((0x2D, shift)), + '=' => Some((0x2E, 0)), + '+' => Some((0x2E, shift)), + '[' => Some((0x2F, 0)), + '{' => Some((0x2F, shift)), + ']' => Some((0x30, 0)), + '}' => Some((0x30, shift)), + '\\' => Some((0x31, 0)), + '|' => Some((0x31, shift)), + ';' => Some((0x33, 0)), + ':' => Some((0x33, shift)), + '\'' => Some((0x34, 0)), + '"' => Some((0x34, shift)), + '`' => Some((0x35, 0)), + '~' => Some((0x35, shift)), + ',' => Some((0x36, 0)), + '<' => Some((0x36, shift)), + '.' => Some((0x37, 0)), + '>' => Some((0x37, shift)), + '/' => Some((0x38, 0)), + '?' => Some((0x38, shift)), + ' ' => Some((0x2C, 0)), + '\n' | '\r' => Some((0x28, 0)), + '\t' => Some((0x2B, 0)), + _ => None, + } +} diff --git a/client/src/input/microphone.rs b/client/src/input/microphone.rs index c692fe1..cf92a0a 100644 --- a/client/src/input/microphone.rs +++ b/client/src/input/microphone.rs @@ -154,3 +154,9 @@ impl MicrophoneCapture { String::new() } } + +impl Drop for MicrophoneCapture { + fn drop(&mut self) { + let _ = self.pipeline.set_state(gst::State::Null); + } +} diff --git a/client/src/input/mouse.rs b/client/src/input/mouse.rs index 8547a5d..635628b 100644 --- a/client/src/input/mouse.rs +++ b/client/src/input/mouse.rs @@ -1,6 +1,6 @@ // client/src/input/mouse.rs -use evdev::{Device, EventType, InputEvent, KeyCode, RelativeAxisCode}; +use evdev::{AbsoluteAxisCode, Device, EventType, InputEvent, KeyCode, RelativeAxisCode}; use std::time::{Duration, Instant}; use tokio::sync::broadcast::{self, Sender}; use tracing::{debug, error, trace, warn}; @@ -21,10 +21,41 @@ pub struct MouseAggregator { dx: i8, dy: i8, wheel: i8, + last_abs_x: Option, + last_abs_y: Option, + abs_scale: i32, + abs_jump_x: i32, + abs_jump_y: i32, + has_touch_state: bool, + touch_guarded: bool, + touch_active: bool, } impl MouseAggregator { pub fn new(dev: Device, dev_mode: bool, tx: Sender) -> Self { + let abs_scale = std::env::var("LESAVKA_TOUCHPAD_SCALE") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(8) + .max(1); + let has_touch_state = dev + .supported_keys() + .map(|keys| keys.contains(KeyCode::BTN_TOUCH)) + .unwrap_or(false) + || dev + .supported_absolute_axes() + .map(|abs| abs.contains(AbsoluteAxisCode::ABS_MT_TRACKING_ID)) + .unwrap_or(false); + let abs_jump_x = Self::abs_jump_threshold( + &dev, + &[AbsoluteAxisCode::ABS_X, AbsoluteAxisCode::ABS_MT_POSITION_X], + abs_scale, + ); + let abs_jump_y = Self::abs_jump_threshold( + &dev, + &[AbsoluteAxisCode::ABS_Y, AbsoluteAxisCode::ABS_MT_POSITION_Y], + abs_scale, + ); Self { dev, tx, @@ -36,6 +67,14 @@ impl MouseAggregator { dx: 0, dy: 0, wheel: 0, + last_abs_x: None, + last_abs_y: None, + abs_scale, + abs_jump_x, + abs_jump_y, + has_touch_state, + touch_guarded: false, + touch_active: true, } } @@ -84,6 +123,15 @@ impl MouseAggregator { c if c == KeyCode::BTN_LEFT.0 => self.set_btn(0, e.value()), c if c == KeyCode::BTN_RIGHT.0 => self.set_btn(1, e.value()), c if c == KeyCode::BTN_MIDDLE.0 => self.set_btn(2, e.value()), + c if c == KeyCode::BTN_TOUCH.0 => { + self.touch_guarded = true; + self.touch_active = e.value() != 0; + if !self.touch_active { + self.last_abs_x = None; + self.last_abs_y = None; + } + self.set_btn(0, e.value()); + } _ => {} }, EventType::RELATIVE => match e.code() { @@ -98,12 +146,85 @@ impl MouseAggregator { } _ => {} }, + EventType::ABSOLUTE => match e.code() { + c if c == AbsoluteAxisCode::ABS_X.0 + || c == AbsoluteAxisCode::ABS_MT_POSITION_X.0 => + { + if self.touch_guarded && !self.touch_active { + self.last_abs_x = Some(e.value()); + continue; + } + if let Some(prev) = self.last_abs_x { + if !self.has_touch_state { + let delta = (e.value() - prev).abs(); + if delta > self.abs_jump_x { + self.last_abs_x = Some(e.value()); + continue; + } + } + let delta = (e.value() - prev) / self.abs_scale; + if delta != 0 { + self.dx = self.dx.saturating_add(delta.clamp(-127, 127) as i8); + } + } + self.last_abs_x = Some(e.value()); + } + c if c == AbsoluteAxisCode::ABS_Y.0 + || c == AbsoluteAxisCode::ABS_MT_POSITION_Y.0 => + { + if self.touch_guarded && !self.touch_active { + self.last_abs_y = Some(e.value()); + continue; + } + if let Some(prev) = self.last_abs_y { + if !self.has_touch_state { + let delta = (e.value() - prev).abs(); + if delta > self.abs_jump_y { + self.last_abs_y = Some(e.value()); + continue; + } + } + let delta = (e.value() - prev) / self.abs_scale; + if delta != 0 { + self.dy = self.dy.saturating_add(delta.clamp(-127, 127) as i8); + } + } + self.last_abs_y = Some(e.value()); + } + c if c == AbsoluteAxisCode::ABS_MT_TRACKING_ID.0 => { + if e.value() < 0 { + self.touch_guarded = true; + self.touch_active = false; + self.last_abs_x = None; + self.last_abs_y = None; + } else { + self.touch_guarded = true; + self.touch_active = true; + } + } + _ => {} + }, EventType::SYNCHRONIZATION => self.flush(), _ => {} } } } + pub fn reset_state(&mut self) { + self.buttons = 0; + self.last_buttons = 0; + self.dx = 0; + self.dy = 0; + self.wheel = 0; + self.last_abs_x = None; + self.last_abs_y = None; + if !self.sending_disabled { + let _ = self.tx.send(MouseReport { + data: [0; 4].into(), + }); + } + } + fn flush(&mut self) { if self.buttons == self.last_buttons && Instant::now() < self.next_send { return; @@ -143,6 +264,27 @@ impl MouseAggregator { self.buttons &= !(1 << bit) } } + + fn abs_jump_threshold(dev: &Device, codes: &[AbsoluteAxisCode], abs_scale: i32) -> i32 { + let mut range: Option = None; + if let Ok(iter) = dev.get_absinfo() { + for (code, info) in iter { + if codes.iter().any(|c| *c == code) { + range = Some(info.maximum() - info.minimum()); + break; + } + } + } + let mut threshold = range.unwrap_or(0).abs() / 3; + let min = (abs_scale * 40).max(50); + if threshold < min { + threshold = min; + } + if threshold == 0 { + threshold = min; + } + threshold + } } impl Drop for MouseAggregator { diff --git a/client/src/lib.rs b/client/src/lib.rs index cd14970..99b91d4 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -7,5 +7,6 @@ pub mod handshake; pub mod input; pub mod layout; pub mod output; +pub mod paste; pub use app::LesavkaClientApp; diff --git a/client/src/output/layout.rs b/client/src/output/layout.rs index 42d5b0f..6f70ebe 100644 --- a/client/src/output/layout.rs +++ b/client/src/output/layout.rs @@ -29,54 +29,40 @@ pub fn assign_rectangles( match monitors.len() { 0 => return rects, // impossible, but keep compiler happy 1 => { - // One monitor: side-by-side layout + // One monitor: side-by-side layout, full height let m = &monitors[0].geometry; - let total_native_width: i32 = streams.iter().map(|(_, w, _)| *w).sum(); - let scale = f64::min( - m.width() as f64 / total_native_width as f64, - m.height() as f64 / streams[0].2 as f64, - ); - debug!("one-monitor scale = {}", scale); - + let count = streams.len().max(1) as i32; + let base_w = m.width() / count; let mut x = m.x(); - for (idx, &(_, w, h)) in streams.iter().enumerate() { - let ww = (w as f64 * scale).round() as i32; - let hh = (h as f64 * scale).round() as i32; + for (idx, _stream) in streams.iter().enumerate() { + let w = if idx == streams.len() - 1 { + m.width() - base_w * (count - 1) + } else { + base_w + }; rects[idx] = Rect { x, y: m.y(), - w: ww, - h: hh, + w, + h: m.height(), }; - x += ww; + x += w; } } _ => { - // ≥2 monitors: map 1-to-1 until we run out - for (idx, stream) in streams.iter().enumerate() { + // ≥2 monitors: map 1-to-1 until we run out, full screen per monitor + for (idx, _stream) in streams.iter().enumerate() { if idx >= monitors.len() { break; } let m = &monitors[idx]; let geom = m.geometry; - let (w, h) = (stream.1, stream.2); - - let scale = f64::min( - geom.width() as f64 / w as f64, - geom.height() as f64 / h as f64, - ); - debug!("monitor#{idx} scale = {scale}"); - - let ww = (w as f64 * scale).round() as i32; - let hh = (h as f64 * scale).round() as i32; - let xx = geom.x() + (geom.width() - ww) / 2; - let yy = geom.y() + (geom.height() - hh) / 2; rects[idx] = Rect { - x: xx, - y: yy, - w: ww, - h: hh, + x: geom.x(), + y: geom.y(), + w: geom.width(), + h: geom.height(), }; } } diff --git a/client/src/output/video.rs b/client/src/output/video.rs index 84842dd..acdd2c5 100644 --- a/client/src/output/video.rs +++ b/client/src/output/video.rs @@ -70,10 +70,10 @@ impl MonitorWindow { if let Ok(overlay) = sink_elem.dynamic_cast::() { if let Some(r) = rects.get(id as usize) { // 1. Tell glimagesink how to crop the texture in its own window - let _ = overlay.set_render_rectangle(r.x, r.y, r.w, r.h); + let _ = overlay.set_render_rectangle(0, 0, r.w, r.h); debug!( "🔲 eye-{id} → render_rectangle({}, {}, {}, {})", - r.x, r.y, r.w, r.h + 0, 0, r.w, r.h ); // 2. **Compositor-level** placement (Wayland only) @@ -242,3 +242,9 @@ impl MonitorWindow { let _ = self.src.push_buffer(buf); // ignore Eos/flushing } } + +impl Drop for MonitorWindow { + fn drop(&mut self) { + let _ = self._pipeline.set_state(gst::State::Null); + } +} diff --git a/client/src/paste.rs b/client/src/paste.rs new file mode 100644 index 0000000..b39d59a --- /dev/null +++ b/client/src/paste.rs @@ -0,0 +1,73 @@ +// client/src/paste.rs +#![forbid(unsafe_code)] + +use anyhow::{Context, Result}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use chacha20poly1305::aead::{Aead, KeyInit, OsRng, rand_core::RngCore}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + +use lesavka_common::lesavka::PasteRequest; + +pub fn build_paste_request(text: &str) -> Result { + let max = std::env::var("LESAVKA_PASTE_MAX") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(4096); + let text = if text.chars().count() > max { + text.chars().take(max).collect::() + } else { + text.to_string() + }; + let key = load_key()?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, text.as_bytes()) + .map_err(|e| anyhow::anyhow!("paste encrypt failed: {e}"))?; + + Ok(PasteRequest { + nonce: nonce_bytes.to_vec(), + data: ciphertext, + encrypted: true, + }) +} + +fn load_key() -> Result<[u8; 32]> { + let raw = std::env::var("LESAVKA_PASTE_KEY") + .context("LESAVKA_PASTE_KEY not set (required for PasteText RPC)")?; + decode_key(&raw) +} + +fn decode_key(raw: &str) -> Result<[u8; 32]> { + let s = raw.trim(); + let s = s.strip_prefix("hex:").unwrap_or(s); + let bytes = if s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) { + hex_to_bytes(s)? + } else { + STANDARD + .decode(s.as_bytes()) + .context("LESAVKA_PASTE_KEY must be 32-byte base64 or 64-char hex")? + }; + if bytes.len() != 32 { + anyhow::bail!("LESAVKA_PASTE_KEY must decode to 32 bytes"); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn hex_to_bytes(s: &str) -> Result> { + let mut out = Vec::with_capacity(s.len() / 2); + let chars: Vec = s.chars().collect(); + for i in (0..chars.len()).step_by(2) { + let hi = chars[i].to_digit(16).context("hex decode failed")?; + let lo = chars[i + 1].to_digit(16).context("hex decode failed")?; + out.push(((hi << 4) | lo) as u8); + } + Ok(out) +} diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index b1f20a6..8a8ea91 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -11,6 +11,13 @@ message AudioPacket { uint32 id = 1; uint64 pts = 2; bytes data = 3; } message ResetUsbReply { bool ok = 1; } // true = success +message PasteRequest { + bytes nonce = 1; + bytes data = 2; + bool encrypted = 3; +} +message PasteReply { bool ok = 1; string error = 2; } + message HandshakeSet { bool camera = 1; bool microphone = 2; @@ -31,6 +38,7 @@ service Relay { rpc StreamMicrophone (stream AudioPacket) returns (stream Empty); rpc StreamCamera (stream VideoPacket) returns (stream Empty); + rpc PasteText (PasteRequest) returns (PasteReply); rpc ResetUsb (Empty) returns (ResetUsbReply); } diff --git a/common/src/bin/cli.rs b/common/src/bin/cli.rs index 39a8920..6e686c9 100644 --- a/common/src/bin/cli.rs +++ b/common/src/bin/cli.rs @@ -1,3 +1,3 @@ fn main() { lesavka_common::run_cli(); -} \ No newline at end of file +} diff --git a/docker/client.Dockerfile b/docker/client.Dockerfile new file mode 100644 index 0000000..0f3e286 --- /dev/null +++ b/docker/client.Dockerfile @@ -0,0 +1,49 @@ +FROM rust:1.87-bookworm AS builder + +WORKDIR /src + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libglib2.0-dev \ + libgtk-4-dev \ + libasound2-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY . . + +RUN cargo build --release --manifest-path client/Cargo.toml + +FROM debian:bookworm-slim + +LABEL org.opencontainers.image.source="https://scm.bstein.dev/bstein/lesavka" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgtk-4-1 \ + libgstreamer1.0-0 \ + libgstreamer-plugins-base1.0-0 \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly \ + gstreamer1.0-libav \ + libglib2.0-0 \ + libasound2 \ + libx11-6 \ + libxext6 \ + libxfixes3 \ + libxi6 \ + libxrandr2 \ + libxrender1 \ + libxkbcommon0 \ + libwayland-client0 \ + libwayland-cursor0 \ + libwayland-egl1 \ + libegl1 \ + libgl1 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /src/client/target/release/lesavka-client /usr/local/bin/lesavka-client + +ENTRYPOINT ["lesavka-client"] diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile new file mode 100644 index 0000000..c994352 --- /dev/null +++ b/docker/server.Dockerfile @@ -0,0 +1,38 @@ +FROM rust:1.87-bookworm AS builder + +WORKDIR /src + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libglib2.0-dev \ + libudev-dev \ + libasound2-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY . . + +RUN cargo build --release --manifest-path server/Cargo.toml + +FROM debian:bookworm-slim + +LABEL org.opencontainers.image.source="https://scm.bstein.dev/bstein/lesavka" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgstreamer1.0-0 \ + libgstreamer-plugins-base1.0-0 \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly \ + gstreamer1.0-libav \ + libglib2.0-0 \ + libudev1 \ + libasound2 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /src/server/target/release/lesavka-server /usr/local/bin/lesavka-server +COPY scripts/daemon/lesavka-core.sh /usr/local/bin/lesavka-core.sh + +ENTRYPOINT ["lesavka-server"] diff --git a/scripts/ansible/deploy-client.yml b/scripts/ansible/deploy-client.yml new file mode 100644 index 0000000..50f02d9 --- /dev/null +++ b/scripts/ansible/deploy-client.yml @@ -0,0 +1,32 @@ +--- +- name: Deploy lesavka client binary + hosts: lesavka_client + become: true + vars: + lesavka_artifact_url: "" + lesavka_artifact_dest: "/tmp/lesavka-client.tar.gz" + lesavka_install_dir: "/usr/local/bin" + tasks: + - name: Ensure artifact URL is set + assert: + that: + - lesavka_artifact_url | length > 0 + fail_msg: "Set lesavka_artifact_url to the Jenkins artifact tarball" + + - name: Download client artifact + get_url: + url: "{{ lesavka_artifact_url }}" + dest: "{{ lesavka_artifact_dest }}" + mode: "0644" + + - name: Unpack client artifact + unarchive: + src: "{{ lesavka_artifact_dest }}" + dest: "{{ lesavka_install_dir }}" + remote_src: true + + - name: Restart lesavka-client + systemd: + name: lesavka-client + state: restarted + daemon_reload: true diff --git a/scripts/ansible/deploy-server.yml b/scripts/ansible/deploy-server.yml new file mode 100644 index 0000000..ffb8582 --- /dev/null +++ b/scripts/ansible/deploy-server.yml @@ -0,0 +1,32 @@ +--- +- name: Deploy lesavka server binary + hosts: lesavka_server + become: true + vars: + lesavka_artifact_url: "" + lesavka_artifact_dest: "/tmp/lesavka-server.tar.gz" + lesavka_install_dir: "/usr/local/bin" + tasks: + - name: Ensure artifact URL is set + assert: + that: + - lesavka_artifact_url | length > 0 + fail_msg: "Set lesavka_artifact_url to the Jenkins artifact tarball" + + - name: Download server artifact + get_url: + url: "{{ lesavka_artifact_url }}" + dest: "{{ lesavka_artifact_dest }}" + mode: "0644" + + - name: Unpack server artifact + unarchive: + src: "{{ lesavka_artifact_dest }}" + dest: "{{ lesavka_install_dir }}" + remote_src: true + + - name: Restart lesavka-server + systemd: + name: lesavka-server + state: restarted + daemon_reload: true diff --git a/scripts/ci/build-dist.sh b/scripts/ci/build-dist.sh new file mode 100755 index 0000000..4fbe640 --- /dev/null +++ b/scripts/ci/build-dist.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +VERSION=$(awk -F'"' '/^version\s*=/{print $2; exit}' "${ROOT_DIR}/server/Cargo.toml") +TARGET=$(rustc -vV | awk '/^host:/{print $2}') +GIT_SHA=$(git -C "${ROOT_DIR}" rev-parse --short HEAD) +DIST_DIR="${ROOT_DIR}/dist" + +mkdir -p "${DIST_DIR}" + +echo "Building server (release)..." +cargo build --release --manifest-path "${ROOT_DIR}/server/Cargo.toml" + +SERVER_TAR="${DIST_DIR}/lesavka-server-${VERSION}-${TARGET}-${GIT_SHA}.tar.gz" +SERVER_TMP=$(mktemp -d) +install -Dm755 "${ROOT_DIR}/server/target/release/lesavka-server" "${SERVER_TMP}/lesavka-server" +install -Dm755 "${ROOT_DIR}/scripts/daemon/lesavka-core.sh" "${SERVER_TMP}/lesavka-core.sh" +tar -czf "${SERVER_TAR}" -C "${SERVER_TMP}" lesavka-server lesavka-core.sh +rm -rf "${SERVER_TMP}" + +echo "Building client (release)..." +cargo build --release --manifest-path "${ROOT_DIR}/client/Cargo.toml" + +CLIENT_TAR="${DIST_DIR}/lesavka-client-${VERSION}-${TARGET}-${GIT_SHA}.tar.gz" +CLIENT_TMP=$(mktemp -d) +install -Dm755 "${ROOT_DIR}/client/target/release/lesavka-client" "${CLIENT_TMP}/lesavka-client" +tar -czf "${CLIENT_TAR}" -C "${CLIENT_TMP}" lesavka-client +rm -rf "${CLIENT_TMP}" + +echo "Artifacts written to ${DIST_DIR}" diff --git a/scripts/ci/build-images.sh b/scripts/ci/build-images.sh new file mode 100755 index 0000000..097c086 --- /dev/null +++ b/scripts/ci/build-images.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +REGISTRY=${REGISTRY:-registry.bstein.dev} +IMAGE_PREFIX=${IMAGE_PREFIX:-${REGISTRY}/lesavka} +IMAGE_TAG=${IMAGE_TAG:-$(git -C "${ROOT_DIR}" rev-parse --short HEAD)} +PUSH_IMAGES=${PUSH_IMAGES:-1} +PLATFORMS=${PLATFORMS:-linux/amd64,linux/arm64} + +should_push() { + case "${PUSH_IMAGES}" in + 1|true|TRUE|yes|YES) return 0 ;; + *) return 1 ;; + esac +} + +build_with_buildx() { + local dockerfile=$1 + local image=$2 + + if should_push; then + docker buildx build \ + --platform "${PLATFORMS}" \ + -f "${dockerfile}" \ + -t "${image}:${IMAGE_TAG}" \ + -t "${image}:latest" \ + --push \ + "${ROOT_DIR}" + else + docker buildx build \ + --platform "${PLATFORMS}" \ + -f "${dockerfile}" \ + -t "${image}:${IMAGE_TAG}" \ + -t "${image}:latest" \ + --load \ + "${ROOT_DIR}" + fi +} + +build_with_docker() { + local dockerfile=$1 + local image=$2 + + docker build \ + -f "${dockerfile}" \ + -t "${image}:${IMAGE_TAG}" \ + -t "${image}:latest" \ + "${ROOT_DIR}" + + if should_push; then + docker push "${image}:${IMAGE_TAG}" + docker push "${image}:latest" + fi +} + +if docker buildx version >/dev/null 2>&1; then + build_with_buildx "${ROOT_DIR}/docker/server.Dockerfile" "${IMAGE_PREFIX}/server" + build_with_buildx "${ROOT_DIR}/docker/client.Dockerfile" "${IMAGE_PREFIX}/client" +else + build_with_docker "${ROOT_DIR}/docker/server.Dockerfile" "${IMAGE_PREFIX}/server" + build_with_docker "${ROOT_DIR}/docker/client.Dockerfile" "${IMAGE_PREFIX}/client" +fi diff --git a/scripts/daemon/lesavka-uvc.sh b/scripts/daemon/lesavka-uvc.sh index 25c3cd0..a4d3e10 100644 --- a/scripts/daemon/lesavka-uvc.sh +++ b/scripts/daemon/lesavka-uvc.sh @@ -2,6 +2,12 @@ # scripts/daemon/lesavka-uvc.sh - launch UVC control helper as a standalone service set -euo pipefail +# Optional env file for runtime overrides (debug, width/fps, etc.) +if [[ -r /etc/lesavka/uvc.env ]]; then + # shellcheck disable=SC1091 + source /etc/lesavka/uvc.env +fi + DEV=${LESAVKA_UVC_DEV:-/dev/v4l/by-path/platform-1000480000.usb-video-index0} if [[ ! -e "$DEV" ]]; then DEV=/dev/video0 diff --git a/scripts/install/client.sh b/scripts/install/client.sh index ff2c0a0..e270f26 100755 --- a/scripts/install/client.sh +++ b/scripts/install/client.sh @@ -3,20 +3,34 @@ set -euo pipefail ORIG_USER=${SUDO_USER:-$(id -un)} +SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel 2>/dev/null || true) # 1. packages (Arch) sudo pacman -Syq --needed --noconfirm \ git rustup protobuf gcc clang evtest base-devel \ gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \ pipewire pipewire-pulse \ - wmctrl qt6-tools + wmctrl qt6-tools wl-clipboard xclip xsel -# 1b. yay for AUR bits (run as the invoking user, never root) -if ! command -v yay >/dev/null 2>&1; then - sudo -u "$ORIG_USER" bash -c 'cd /tmp && git clone --depth 1 https://aur.archlinux.org/yay.git && - cd yay && makepkg -si --noconfirm' +ensure_yay() { + if command -v yay >/dev/null 2>&1; then + if sudo -u "$ORIG_USER" yay --version >/dev/null 2>&1; then + return + fi + fi + sudo -u "$ORIG_USER" bash -c 'rm -rf /tmp/yay && + cd /tmp && git clone --depth 1 https://aur.archlinux.org/yay.git && + cd yay && makepkg -si --noconfirm' +} + +# 1b. grpcurl (prefer repo package; fallback to AUR if needed) +if sudo pacman -Si grpcurl >/dev/null 2>&1; then + sudo pacman -Syq --needed --noconfirm grpcurl +else + ensure_yay + sudo -u "$ORIG_USER" yay -S --needed --noconfirm grpcurl-bin fi -sudo -u "$ORIG_USER" yay -S --needed --noconfirm grpcurl-bin # 1c. input access sudo usermod -aG input "$ORIG_USER" @@ -25,14 +39,19 @@ sudo usermod -aG input "$ORIG_USER" sudo rustup default stable sudo -u "$ORIG_USER" rustup default stable -# 3. clone / update into a user-writable dir +# 3. clone / update into a user-writable dir (or use local repo if present) USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6) -SRC="$USER_HOME/.local/src/lesavka" -sudo -u "$ORIG_USER" mkdir -p "$(dirname "$SRC")" -if [[ -d $SRC/.git ]]; then - sudo -u "$ORIG_USER" git -C "$SRC" pull --ff-only +if [[ -n ${REPO_ROOT:-} && -d $REPO_ROOT/.git ]]; then + SRC="$REPO_ROOT" + echo "==> 3. Using local repo at $SRC" else - sudo -u "$ORIG_USER" git clone "$PWD" "$SRC" + SRC="$USER_HOME/.local/src/lesavka" + sudo -u "$ORIG_USER" mkdir -p "$(dirname "$SRC")" + if [[ -d $SRC/.git ]]; then + sudo -u "$ORIG_USER" git -C "$SRC" pull --ff-only + else + sudo -u "$ORIG_USER" git clone "$PWD" "$SRC" + fi fi # 4. build @@ -68,3 +87,6 @@ EOF sudo systemctl daemon-reload sudo systemctl enable --now lesavka-client.service sudo systemctl restart lesavka-client || true + +echo "✅ lesavka-client installed to /usr/local/bin/lesavka-client" +echo "➡️ Run: /usr/local/bin/lesavka-client (set LESAVKA_SERVER_ADDR as needed)" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 1c5a152..59602b3 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -247,6 +247,7 @@ KillMode=process TimeoutStopSec=10 StandardError=append:/tmp/lesavka-uvc.stderr User=root +EnvironmentFile=-/etc/lesavka/uvc.env [Install] WantedBy=multi-user.target diff --git a/server/Cargo.toml b/server/Cargo.toml index 7eb3c4f..eff05d8 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -25,6 +25,8 @@ gstreamer-video = "0.23" udev = "0.8" prost-types = "0.13" chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] } +chacha20poly1305 = "0.10" +base64 = "0.22" [build-dependencies] prost-build = "0.13" diff --git a/server/src/audio.rs b/server/src/audio.rs index a03540a..6edfdd8 100644 --- a/server/src/audio.rs +++ b/server/src/audio.rs @@ -2,7 +2,6 @@ #![forbid(unsafe_code)] use anyhow::{Context, anyhow}; -use chrono::Local; use futures_util::Stream; use gst::ElementFactory; use gst::MessageView::*; @@ -10,7 +9,7 @@ use gst::prelude::*; use gstreamer as gst; use gstreamer_app as gst_app; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use tokio_stream::wrappers::ReceiverStream; use tonic::Status; use tracing::{debug, error, warn}; @@ -256,22 +255,55 @@ impl Voice { let decodebin = gst::ElementFactory::make("decodebin") .build() .context("make decodebin")?; + let convert = gst::ElementFactory::make("audioconvert") + .build() + .context("make audioconvert")?; + let resample = gst::ElementFactory::make("audioresample") + .build() + .context("make audioresample")?; + let caps = gst::Caps::builder("audio/x-raw") + .field("format", "S16LE") + .field("channels", 2i32) + .field("rate", 48_000i32) + .build(); + let capsfilter = gst::ElementFactory::make("capsfilter") + .property("caps", &caps) + .build() + .context("make capsfilter")?; let alsa_sink = gst::ElementFactory::make("alsasink") .build() .context("make alsasink")?; alsa_sink.set_property("device", &alsa_dev); - pipeline.add_many(&[appsrc.upcast_ref(), &decodebin, &alsa_sink])?; + pipeline.add_many(&[ + appsrc.upcast_ref(), + &decodebin, + &convert, + &resample, + &capsfilter, + &alsa_sink, + ])?; appsrc.link(&decodebin)?; + gst::Element::link_many(&[&convert, &resample, &capsfilter, &alsa_sink])?; /*------------ decodebin autolink ----------------*/ - let sink_clone = alsa_sink.clone(); // keep original for later + let convert_sink = convert + .static_pad("sink") + .context("audioconvert sink pad")?; decodebin.connect_pad_added(move |_db, pad| { - let sink_pad = sink_clone.static_pad("sink").unwrap(); - if !sink_pad.is_linked() { - let _ = pad.link(&sink_pad); + if convert_sink.is_linked() { + return; } + let caps = pad.current_caps().unwrap_or_else(|| pad.query_caps(None)); + let is_audio = caps + .structure(0) + .map(|s| s.name().starts_with("audio/")) + .unwrap_or(false); + if !is_audio { + return; + } + let _ = pad.link(&convert_sink); }); // underrun ≠ error – just show a warning @@ -290,8 +322,6 @@ impl Voice { } pub fn push(&mut self, pkt: &AudioPacket) { - use gst::prelude::*; - self.tap.feed(&pkt.data); let mut buf = gst::Buffer::from_slice(pkt.data.clone()); diff --git a/server/src/bin/lesavka-uvc.rs b/server/src/bin/lesavka-uvc.rs index 31ae21b..03c11ed 100644 --- a/server/src/bin/lesavka-uvc.rs +++ b/server/src/bin/lesavka-uvc.rs @@ -311,9 +311,7 @@ impl UvcConfig { max_packet ); } - if let Some(cfg_max) = read_u32_file( - &format!("{CONFIGFS_UVC_BASE}/streaming_maxpacket"), - ) { + if let Some(cfg_max) = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxpacket")) { if max_packet > cfg_max { eprintln!( "[lesavka-uvc] configfs maxpacket {}: clamp max_packet {} -> {}", @@ -820,13 +818,19 @@ fn read_u32_first(path: &str) -> Option { } fn read_configfs_snapshot() -> Option { - let width = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/wWidth"))?; - let height = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/wHeight"))?; - let default_interval = - read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/dwDefaultFrameInterval"))?; - let frame_interval = - read_u32_first(&format!("{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/dwFrameInterval")) - .unwrap_or(0); + let width = read_u32_file(&format!( + "{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/wWidth" + ))?; + let height = read_u32_file(&format!( + "{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/wHeight" + ))?; + let default_interval = read_u32_file(&format!( + "{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/dwDefaultFrameInterval" + ))?; + let frame_interval = read_u32_first(&format!( + "{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/dwFrameInterval" + )) + .unwrap_or(0); let maxpacket = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxpacket"))?; let maxburst = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxburst")).unwrap_or(0); Some(ConfigfsSnapshot { diff --git a/server/src/camera.rs b/server/src/camera.rs index ca7da57..e688645 100644 --- a/server/src/camera.rs +++ b/server/src/camera.rs @@ -70,9 +70,7 @@ pub fn current_camera_config() -> CameraConfig { fn select_camera_config() -> CameraConfig { let output_env = std::env::var("LESAVKA_CAM_OUTPUT").ok(); - let output_override = output_env - .as_deref() - .and_then(parse_camera_output); + let output_override = output_env.as_deref().and_then(parse_camera_output); let require_connected = output_override != Some(CameraOutput::Hdmi); let hdmi = detect_hdmi_connector(require_connected); @@ -204,15 +202,12 @@ fn detect_hdmi_connector(require_connected: bool) -> Option { connectors.push((name, status, id)); } - let matches_preferred = |name: &str, preferred: &str| { - name == preferred || name.ends_with(preferred) - }; + let matches_preferred = + |name: &str, preferred: &str| name == preferred || name.ends_with(preferred); if let Some(pref) = preferred.as_deref() { for (name, status, id) in &connectors { - if matches_preferred(name, pref) - && (!require_connected || status == "connected") - { + if matches_preferred(name, pref) && (!require_connected || status == "connected") { return Some(HdmiConnector { name: name.clone(), id: *id, diff --git a/server/src/gadget.rs b/server/src/gadget.rs index ea0d631..b5b423b 100644 --- a/server/src/gadget.rs +++ b/server/src/gadget.rs @@ -120,7 +120,10 @@ impl UsbGadget { match Self::state(&ctrl) { Ok(state) if !force_cycle - && matches!(state.as_str(), "configured" | "addressed" | "default" | "suspended") => + && matches!( + state.as_str(), + "configured" | "addressed" | "default" | "suspended" + ) => { warn!( "🔒 refusing gadget cycle while host attached (state={state}); set LESAVKA_GADGET_FORCE_CYCLE=1 to override" diff --git a/server/src/handshake.rs b/server/src/handshake.rs index ce18e86..5b54dbd 100644 --- a/server/src/handshake.rs +++ b/server/src/handshake.rs @@ -1,11 +1,11 @@ // ─── server/src/handshake.rs ─────────────────────────────────────────────── use tonic::{Request, Response, Status}; +use crate::camera; use lesavka_common::lesavka::{ Empty, HandshakeSet, handshake_server::{Handshake, HandshakeServer}, }; -use crate::camera; pub struct HandshakeSvc; diff --git a/server/src/lib.rs b/server/src/lib.rs index 1e6a2f1..e10d805 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -4,4 +4,5 @@ pub mod audio; pub mod camera; pub mod gadget; pub mod handshake; +pub mod paste; pub mod video; diff --git a/server/src/main.rs b/server/src/main.rs index 964d067..3eea8ba 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -19,11 +19,12 @@ use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*}; use lesavka_common::lesavka::{ - AudioPacket, Empty, KeyboardReport, MonitorRequest, MouseReport, ResetUsbReply, VideoPacket, + AudioPacket, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply, PasteRequest, + ResetUsbReply, VideoPacket, relay_server::{Relay, RelayServer}, }; -use lesavka_server::{audio, camera, gadget::UsbGadget, handshake::HandshakeSvc, video}; +use lesavka_server::{audio, camera, gadget::UsbGadget, handshake::HandshakeSvc, paste, video}; /*──────────────── constants ────────────────*/ const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -489,6 +490,21 @@ impl Relay for Handler { Ok(Response::new(Box::pin(s))) } + async fn paste_text(&self, req: Request) -> Result, Status> { + let req = req.into_inner(); + let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?; + if let Err(e) = paste::type_text(self.kb.as_ref(), &text).await { + return Ok(Response::new(PasteReply { + ok: false, + error: format!("{e}"), + })); + } + Ok(Response::new(PasteReply { + ok: true, + error: String::new(), + })) + } + /*────────────── USB-reset RPC ────────────*/ async fn reset_usb(&self, _req: Request) -> Result, Status> { info!("🔴 explicit ResetUsb() called"); diff --git a/server/src/paste.rs b/server/src/paste.rs new file mode 100644 index 0000000..681ebce --- /dev/null +++ b/server/src/paste.rs @@ -0,0 +1,132 @@ +// server/src/paste.rs +#![forbid(unsafe_code)] + +use anyhow::{Context, Result}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use tokio::fs::File; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; + +use lesavka_common::lesavka::PasteRequest; + +pub fn decrypt(req: &PasteRequest) -> Result { + if !req.encrypted { + anyhow::bail!("paste request must be encrypted"); + } + let key = load_key()?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let nonce = Nonce::from_slice(&req.nonce); + let plaintext = cipher + .decrypt(nonce, req.data.as_ref()) + .map_err(|e| anyhow::anyhow!("paste decrypt failed: {e}"))?; + Ok(String::from_utf8(plaintext).context("paste plaintext not UTF-8")?) +} + +pub async fn type_text(kb: &Mutex, text: &str) -> Result<()> { + let max = std::env::var("LESAVKA_PASTE_MAX") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(4096); + let delay_ms = std::env::var("LESAVKA_PASTE_DELAY_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + let delay = std::time::Duration::from_millis(delay_ms); + + let mut kb = kb.lock().await; + for c in text.chars().take(max) { + if let Some((usage, mods)) = char_to_usage(c) { + let report = [mods, 0, usage, 0, 0, 0, 0, 0]; + kb.write_all(&report).await?; + kb.write_all(&[0u8; 8]).await?; + if delay_ms > 0 { + tokio::time::sleep(delay).await; + } + } + } + Ok(()) +} + +fn load_key() -> Result<[u8; 32]> { + let raw = std::env::var("LESAVKA_PASTE_KEY") + .context("LESAVKA_PASTE_KEY not set (required for PasteText RPC)")?; + decode_key(&raw) +} + +fn decode_key(raw: &str) -> Result<[u8; 32]> { + let s = raw.trim(); + let s = s.strip_prefix("hex:").unwrap_or(s); + let bytes = if s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) { + hex_to_bytes(s)? + } else { + STANDARD + .decode(s.as_bytes()) + .context("LESAVKA_PASTE_KEY must be 32-byte base64 or 64-char hex")? + }; + if bytes.len() != 32 { + anyhow::bail!("LESAVKA_PASTE_KEY must decode to 32 bytes"); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn hex_to_bytes(s: &str) -> Result> { + let mut out = Vec::with_capacity(s.len() / 2); + let chars: Vec = s.chars().collect(); + for i in (0..chars.len()).step_by(2) { + let hi = chars[i].to_digit(16).context("hex decode failed")?; + let lo = chars[i + 1].to_digit(16).context("hex decode failed")?; + out.push(((hi << 4) | lo) as u8); + } + Ok(out) +} + +fn char_to_usage(c: char) -> Option<(u8, u8)> { + let shift = 0x02; // left shift in HID modifier byte + match c { + 'a'..='z' => Some((0x04 + (c as u8 - b'a'), 0)), + 'A'..='Z' => Some((0x04 + (c as u8 - b'A'), shift)), + '1'..='9' => Some((0x1E + (c as u8 - b'1'), 0)), + '0' => Some((0x27, 0)), + '!' => Some((0x1E, shift)), + '@' => Some((0x1F, shift)), + '#' => Some((0x20, shift)), + '$' => Some((0x21, shift)), + '%' => Some((0x22, shift)), + '^' => Some((0x23, shift)), + '&' => Some((0x24, shift)), + '*' => Some((0x25, shift)), + '(' => Some((0x26, shift)), + ')' => Some((0x27, shift)), + '-' => Some((0x2D, 0)), + '_' => Some((0x2D, shift)), + '=' => Some((0x2E, 0)), + '+' => Some((0x2E, shift)), + '[' => Some((0x2F, 0)), + '{' => Some((0x2F, shift)), + ']' => Some((0x30, 0)), + '}' => Some((0x30, shift)), + '\\' => Some((0x31, 0)), + '|' => Some((0x31, shift)), + ';' => Some((0x33, 0)), + ':' => Some((0x33, shift)), + '\'' => Some((0x34, 0)), + '"' => Some((0x34, shift)), + '`' => Some((0x35, 0)), + '~' => Some((0x35, shift)), + ',' => Some((0x36, 0)), + '<' => Some((0x36, shift)), + '.' => Some((0x37, 0)), + '>' => Some((0x37, shift)), + '/' => Some((0x38, 0)), + '?' => Some((0x38, shift)), + ' ' => Some((0x2C, 0)), + '\n' | '\r' => Some((0x28, 0)), + '\t' => Some((0x2B, 0)), + _ => None, + } +} diff --git a/server/src/video.rs b/server/src/video.rs index c429792..894d777 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -413,7 +413,6 @@ impl WebcamSink { tracing::warn!(target:"lesavka_server::video", %err, "📸⚠️ appsrc push failed"); } } - } pub struct HdmiSink {