master: ship webcam hdmi path + secure paste + deploy tooling

This commit is contained in:
Brad Stein 2026-04-08 20:00:14 -03:00
parent 4f898ddee7
commit eaa03924ed
35 changed files with 1321 additions and 127 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
.git
**/target
**/dist
**/.idea
**/.vscode
**/.cache
**/.DS_Store

89
Jenkinsfile vendored Normal file
View File

@ -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
}
}
}

View File

@ -30,6 +30,8 @@ serde_json = "1.0"
async-stream = "0.3" async-stream = "0.3"
shell-escape = "0.1" shell-escape = "0.1"
v4l = "0.14" v4l = "0.14"
chacha20poly1305 = "0.10"
base64 = "0.22"
[build-dependencies] [build-dependencies]
prost-build = "0.13" prost-build = "0.13"

View File

@ -4,7 +4,7 @@ use anyhow::Result;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration; use std::time::Duration;
use tokio::sync::broadcast; use tokio::sync::{broadcast, mpsc};
use tokio_stream::{StreamExt, wrappers::BroadcastStream}; use tokio_stream::{StreamExt, wrappers::BroadcastStream};
use tonic::{Request, transport::Channel}; use tonic::{Request, transport::Channel};
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
@ -26,6 +26,7 @@ use crate::{
input::microphone::MicrophoneCapture, input::microphone::MicrophoneCapture,
output::audio::AudioOut, output::audio::AudioOut,
output::video::MonitorWindow, output::video::MonitorWindow,
paste,
}; };
pub struct LesavkaClientApp { pub struct LesavkaClientApp {
@ -35,6 +36,7 @@ pub struct LesavkaClientApp {
headless: bool, headless: bool,
kbd_tx: broadcast::Sender<KeyboardReport>, kbd_tx: broadcast::Sender<KeyboardReport>,
mou_tx: broadcast::Sender<MouseReport>, mou_tx: broadcast::Sender<MouseReport>,
paste_rx: Option<mpsc::UnboundedReceiver<String>>,
} }
impl LesavkaClientApp { impl LesavkaClientApp {
@ -48,11 +50,17 @@ impl LesavkaClientApp {
let (kbd_tx, _) = broadcast::channel(1024); let (kbd_tx, _) = broadcast::channel(1024);
let (mou_tx, _) = broadcast::channel(4096); let (mou_tx, _) = broadcast::channel(4096);
let (paste_tx, paste_rx) = mpsc::unbounded_channel();
let agg = if headless { let agg = if headless {
None None
} else { } 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 { Ok(Self {
@ -62,6 +70,7 @@ impl LesavkaClientApp {
headless, headless,
kbd_tx, kbd_tx,
mou_tx, mou_tx,
paste_rx: Some(paste_rx),
}) })
} }
@ -108,6 +117,8 @@ impl LesavkaClientApp {
let mut agg_task = None; let mut agg_task = None;
let mut kbd_loop = None; let mut kbd_loop = None;
let mut mou_loop = None; let mut mou_loop = None;
let mut paste_task = None;
let paste_rx = self.paste_rx.take();
if !self.headless { if !self.headless {
/*────────── input aggregator task (grab after handshake) ─────────────*/ /*────────── input aggregator task (grab after handshake) ─────────────*/
let mut aggregator = self.aggregator.take().expect("InputAggregator present"); let mut aggregator = self.aggregator.take().expect("InputAggregator present");
@ -121,6 +132,9 @@ impl LesavkaClientApp {
/*────────── HID streams (never return) ────────*/ /*────────── HID streams (never return) ────────*/
kbd_loop = Some(self.stream_loop_keyboard(hid_ep.clone())); kbd_loop = Some(self.stream_loop_keyboard(hid_ep.clone()));
mou_loop = Some(self.stream_loop_mouse(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 { } else {
info!("🧪 headless mode: skipping HID input capture"); info!("🧪 headless mode: skipping HID input capture");
} }
@ -222,9 +236,11 @@ impl LesavkaClientApp {
let kbd_loop = kbd_loop.expect("kbd_loop"); let kbd_loop = kbd_loop.expect("kbd_loop");
let mou_loop = mou_loop.expect("mou_loop"); let mou_loop = mou_loop.expect("mou_loop");
let agg_task = agg_task.expect("agg_task"); let agg_task = agg_task.expect("agg_task");
let paste_task = paste_task.expect("paste_task");
tokio::select! { tokio::select! {
_ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); }, _ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); },
_ = mou_loop => { warn!("⚠️🖱️ mouse stream finished"); }, _ = mou_loop => { warn!("⚠️🖱️ mouse stream finished"); },
_ = paste_task => { warn!("⚠️📋 paste loop finished"); },
_ = suicide => { /* handled above */ }, _ = suicide => { /* handled above */ },
r = agg_task => { r = agg_task => {
match r { match r {
@ -232,7 +248,7 @@ impl LesavkaClientApp {
Ok(Err(e)) => error!("input aggregator error: {e:?}"), Ok(Err(e)) => error!("input aggregator error: {e:?}"),
Err(join_err) => error!("aggregator task panicked: {join_err:?}"), Err(join_err) => error!("aggregator task panicked: {join_err:?}"),
} }
std::process::exit(1); return Ok(());
} }
} }
} }
@ -243,6 +259,37 @@ impl LesavkaClientApp {
Ok(()) Ok(())
} }
/*──────────────── paste loop ───────────────*/
fn paste_loop(
ep: Channel,
mut rx: mpsc::UnboundedReceiver<String>,
) -> 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 ───────────────*/ /*──────────────── keyboard stream ───────────────*/
async fn stream_loop_keyboard(&self, ep: Channel) { async fn stream_loop_keyboard(&self, ep: Channel) {
loop { loop {

View File

@ -35,24 +35,44 @@ pub struct CameraCapture {
} }
impl CameraCapture { impl CameraCapture {
pub fn new( pub fn new(device_fragment: Option<&str>, cfg: Option<CameraConfig>) -> anyhow::Result<Self> {
device_fragment: Option<&str>,
cfg: Option<CameraConfig>,
) -> anyhow::Result<Self> {
gst::init().ok(); gst::init().ok();
// Pick device (prefers V4L2 nodes with capture capability) // Select source: V4L2 device or test pattern
let dev = match device_fragment { let (src_desc, dev_label, allow_mjpg_source) = match device_fragment {
Some(path) if path.starts_with("/dev/") => path.to_string(), Some(fragment)
Some(fragment) => Self::find_device(fragment).unwrap_or_else(|| "/dev/video0".into()), if fragment.eq_ignore_ascii_case("test")
None => "/dev/video0".into(), || 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() let use_mjpg_source = allow_mjpg_source
&& (std::env::var("LESAVKA_CAM_MJPG").is_ok()
|| std::env::var("LESAVKA_CAM_FORMAT") || std::env::var("LESAVKA_CAM_FORMAT")
.ok() .ok()
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "mjpg" | "mjpeg" | "jpeg")) .map(|v| matches!(v.to_ascii_lowercase().as_str(), "mjpg" | "mjpeg" | "jpeg"))
.unwrap_or(false); .unwrap_or(false));
let output_mjpeg = cfg let output_mjpeg = cfg
.map(|cfg| matches!(cfg.codec, CameraCodec::Mjpeg)) .map(|cfg| matches!(cfg.codec, CameraCodec::Mjpeg))
.unwrap_or_else(|| { .unwrap_or_else(|| {
@ -71,15 +91,20 @@ impl CameraCapture {
tracing::info!("📸 using MJPG source with software encode"); tracing::info!("📸 using MJPG source with software encode");
} }
if output_mjpeg { if output_mjpeg {
tracing::info!( tracing::info!("📸 outputting MJPEG frames for UVC (quality={jpeg_quality})");
"📸 outputting MJPEG frames for UVC (quality={jpeg_quality})"
);
} else { } else {
tracing::info!("📸 using encoder element: {enc}"); tracing::info!("📸 using encoder element: {enc}");
} }
let width = cfg.map(|cfg| cfg.width).unwrap_or_else(|| env_u32("LESAVKA_CAM_WIDTH", 1280)); let width = cfg
let height = cfg.map(|cfg| cfg.height).unwrap_or_else(|| env_u32("LESAVKA_CAM_HEIGHT", 720)); .map(|cfg| cfg.width)
let fps = cfg.map(|cfg| cfg.fps).unwrap_or_else(|| env_u32("LESAVKA_CAM_FPS", 25)).max(1); .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 have_nvvidconv = gst::ElementFactory::find("nvvidconv").is_some();
let (src_caps, preenc) = match enc { let (src_caps, preenc) = match enc {
// ─────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────
@ -117,14 +142,14 @@ impl CameraCapture {
let desc = if output_mjpeg { let desc = if output_mjpeg {
if use_mjpg_source { if use_mjpg_source {
format!( format!(
"v4l2src device={dev} do-timestamp=true ! \ "{src_desc} ! \
image/jpeg,width={width},height={height},framerate={fps}/1 ! \ image/jpeg,width={width},height={height},framerate={fps}/1 ! \
queue max-size-buffers=30 leaky=downstream ! \ queue max-size-buffers=30 leaky=downstream ! \
appsink name=asink emit-signals=true max-buffers=60 drop=true" appsink name=asink emit-signals=true max-buffers=60 drop=true"
) )
} else { } else {
format!( format!(
"v4l2src device={dev} do-timestamp=true ! \ "{src_desc} ! \
video/x-raw,width={width},height={height},framerate={fps}/1 ! \ video/x-raw,width={width},height={height},framerate={fps}/1 ! \
videoconvert ! jpegenc quality={jpeg_quality} ! \ videoconvert ! jpegenc quality={jpeg_quality} ! \
queue max-size-buffers=30 leaky=downstream ! \ queue max-size-buffers=30 leaky=downstream ! \
@ -133,7 +158,7 @@ impl CameraCapture {
} }
} else if use_mjpg_source { } else if use_mjpg_source {
format!( format!(
"v4l2src device={dev} do-timestamp=true ! \ "{src_desc} ! \
image/jpeg,width={width},height={height} ! \ image/jpeg,width={width},height={height} ! \
jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \ jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \
videoconvert ! {enc} {kf_prop}={kf_val} ! \ videoconvert ! {enc} {kf_prop}={kf_val} ! \
@ -143,7 +168,7 @@ impl CameraCapture {
) )
} else { } else {
format!( format!(
"v4l2src device={dev} do-timestamp=true ! {src_caps} ! \ "{src_desc} ! {src_caps} ! \
{preenc} {enc} {kf_prop}={kf_val} ! \ {preenc} {enc} {kf_prop}={kf_val} ! \
h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \
queue max-size-buffers=30 leaky=downstream ! \ queue max-size-buffers=30 leaky=downstream ! \
@ -165,7 +190,7 @@ impl CameraCapture {
.expect("appsink downcast"); .expect("appsink downcast");
pipeline.set_state(gst::State::Playing)?; pipeline.set_state(gst::State::Playing)?;
tracing::info!("📸 webcam pipeline ▶️ device={dev}"); tracing::info!("📸 webcam pipeline ▶️ device={dev_label}");
Ok(Self { pipeline, sink }) 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);
}
}

View File

@ -1,7 +1,8 @@
// client/src/input/inputs.rs // client/src/input/inputs.rs
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use evdev::{Device, EventType, KeyCode, RelativeAxisCode}; use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
use std::collections::HashSet;
use tokio::{ use tokio::{
sync::broadcast::Sender, sync::broadcast::Sender,
time::{Duration, interval}, time::{Duration, interval},
@ -12,6 +13,7 @@ use lesavka_common::lesavka::{KeyboardReport, MouseReport};
use super::{keyboard::KeyboardAggregator, mouse::MouseAggregator}; use super::{keyboard::KeyboardAggregator, mouse::MouseAggregator};
use crate::layout::{Layout, apply as apply_layout}; use crate::layout::{Layout, apply as apply_layout};
use tokio::sync::mpsc::UnboundedSender;
pub struct InputAggregator { pub struct InputAggregator {
kbd_tx: Sender<KeyboardReport>, kbd_tx: Sender<KeyboardReport>,
@ -19,6 +21,10 @@ pub struct InputAggregator {
dev_mode: bool, dev_mode: bool,
released: bool, released: bool,
magic_active: bool, magic_active: bool,
pending_release: bool,
pending_kill: bool,
pending_keys: HashSet<KeyCode>,
paste_tx: Option<UnboundedSender<String>>,
keyboards: Vec<KeyboardAggregator>, keyboards: Vec<KeyboardAggregator>,
mice: Vec<MouseAggregator>, mice: Vec<MouseAggregator>,
} }
@ -28,6 +34,7 @@ impl InputAggregator {
dev_mode: bool, dev_mode: bool,
kbd_tx: Sender<KeyboardReport>, kbd_tx: Sender<KeyboardReport>,
mou_tx: Sender<MouseReport>, mou_tx: Sender<MouseReport>,
paste_tx: Option<UnboundedSender<String>>,
) -> Self { ) -> Self {
Self { Self {
kbd_tx, kbd_tx,
@ -35,6 +42,10 @@ impl InputAggregator {
dev_mode, dev_mode,
released: false, released: false,
magic_active: false, magic_active: false,
pending_release: false,
pending_kill: false,
pending_keys: HashSet::new(),
paste_tx,
keyboards: Vec::new(), keyboards: Vec::new(),
mice: Vec::new(), mice: Vec::new(),
} }
@ -83,7 +94,12 @@ impl InputAggregator {
// pass dev_mode to aggregator // pass dev_mode to aggregator
// let kbd_agg = KeyboardAggregator::new(dev, self.dev_mode); // 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); self.keyboards.push(kbd_agg);
found_any = true; found_any = true;
continue; continue;
@ -123,14 +139,14 @@ impl InputAggregator {
let mut tick = interval(Duration::from_millis(10)); let mut tick = interval(Duration::from_millis(10));
let mut current = Layout::SideBySide; let mut current = Layout::SideBySide;
loop { 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; let mut want_kill = false;
for kbd in &mut self.keyboards { for kbd in &mut self.keyboards {
kbd.process_events(); kbd.process_events();
want_kill |= kbd.magic_kill(); 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 { if magic_now && !self.magic_active {
self.toggle_grab(); self.toggle_grab();
@ -143,9 +159,47 @@ impl InputAggregator {
}; };
apply_layout(current); apply_layout(current);
} }
if want_kill { if want_kill && !self.pending_kill {
warn!("🧙 magic chord - killing 🪄 AVADA KEDAVRA!!! 💥💀⚰️"); 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 { for mouse in &mut self.mice {
@ -158,20 +212,50 @@ impl InputAggregator {
} }
fn toggle_grab(&mut self) { fn toggle_grab(&mut self) {
if self.pending_release || self.pending_kill {
return;
}
if self.released { if self.released {
tracing::info!("🧙 magic chord - restricting devices 🪄 IMPERIUS!!! 🎮🔒"); tracing::info!("🧙 magic chord - restricting devices 🪄 IMPERIUS!!! 🎮🔒");
} else { } else {
tracing::info!("🧙 magic chord - freeing devices 🪄 EXPELLIARMUS!!! 🔓🕊️"); tracing::info!("🧙 magic chord - freeing devices 🪄 EXPELLIARMUS!!! 🔓🕊️");
} }
if self.released {
// switching to remote control
for k in &mut self.keyboards { for k in &mut self.keyboards {
k.set_grab(self.released); k.reset_state();
k.set_send(self.released); k.set_send(true);
k.set_grab(true);
} }
for m in &mut self.mice { for m in &mut self.mice {
m.set_grab(self.released); m.reset_state();
m.set_send(self.released); 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();
}
}
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 evbits.contains(EventType::RELATIVE) {
if let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), dev.supported_keys()) { if let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), dev.supported_keys()) {
let has_xy = 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 DeviceKind::Other
} }

View File

@ -3,35 +3,53 @@
use evdev::{Device, EventType, InputEvent, KeyCode}; use evdev::{Device, EventType, InputEvent, KeyCode};
use std::{ use std::{
collections::HashSet, collections::HashSet,
sync::atomic::{AtomicU32, Ordering}, sync::atomic::{AtomicU32, AtomicU64, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH},
}; };
use tokio::sync::broadcast::Sender; use tokio::sync::broadcast::Sender;
use tokio::sync::mpsc::UnboundedSender;
use tracing::{debug, error, trace}; use tracing::{debug, error, trace};
use lesavka_common::lesavka::KeyboardReport; 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 { pub struct KeyboardAggregator {
dev: Device, dev: Device,
tx: Sender<KeyboardReport>, tx: Sender<KeyboardReport>,
dev_mode: bool, dev_mode: bool,
sending_disabled: bool, sending_disabled: bool,
paste_enabled: bool,
paste_rpc_enabled: bool,
paste_tx: Option<UnboundedSender<String>>,
pressed_keys: HashSet<KeyCode>, pressed_keys: HashSet<KeyCode>,
} }
/*───────── helpers ───────────────────────────────────────────────────*/ /*───────── helpers ───────────────────────────────────────────────────*/
/// Monotonically-increasing ID that can be logged on server & client. /// Monotonically-increasing ID that can be logged on server & client.
static SEQ: AtomicU32 = AtomicU32::new(0); static SEQ: AtomicU32 = AtomicU32::new(0);
static LAST_PASTE_MS: AtomicU64 = AtomicU64::new(0);
impl KeyboardAggregator { impl KeyboardAggregator {
pub fn new(dev: Device, dev_mode: bool, tx: Sender<KeyboardReport>) -> Self { pub fn new(
dev: Device,
dev_mode: bool,
tx: Sender<KeyboardReport>,
paste_tx: Option<UnboundedSender<String>>,
) -> Self {
let _ = dev.set_nonblocking(true); let _ = dev.set_nonblocking(true);
Self { Self {
dev, dev,
tx, tx,
dev_mode, dev_mode,
sending_disabled: false, 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(), pressed_keys: HashSet::new(),
} }
} }
@ -48,6 +66,10 @@ impl KeyboardAggregator {
self.sending_disabled = !send; self.sending_disabled = !send;
} }
pub fn send_empty_report(&self) {
self.send_report([0; 8]);
}
pub fn process_events(&mut self) { pub fn process_events(&mut self) {
// --- first fetch, then log (avoids aliasing borrow) --- // --- first fetch, then log (avoids aliasing borrow) ---
let events: Vec<InputEvent> = match self.dev.fetch_events() { let events: Vec<InputEvent> = match self.dev.fetch_events() {
@ -74,6 +96,29 @@ impl KeyboardAggregator {
continue; continue;
} }
let code = KeyCode::new(ev.code()); 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() { match ev.value() {
1 => { 1 => {
self.pressed_keys.insert(code); self.pressed_keys.insert(code);
@ -122,6 +167,10 @@ impl KeyboardAggregator {
self.pressed_keys.contains(&kc) self.pressed_keys.contains(&kc)
} }
pub fn pressed_keys_snapshot(&self) -> Vec<KeyCode> {
self.pressed_keys.iter().copied().collect()
}
pub fn magic_grab(&self) -> bool { pub fn magic_grab(&self) -> bool {
self.has_key(KeyCode::KEY_LEFTCTRL) self.has_key(KeyCode::KEY_LEFTCTRL)
&& self.has_key(KeyCode::KEY_LEFTSHIFT) && self.has_key(KeyCode::KEY_LEFTSHIFT)
@ -143,6 +192,152 @@ impl KeyboardAggregator {
pub fn magic_kill(&self) -> bool { pub fn magic_kill(&self) -> bool {
self.has_key(KeyCode::KEY_LEFTCTRL) && self.has_key(KeyCode::KEY_ESC) 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::<u64>().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::<usize>().ok())
.unwrap_or(4096);
let delay_ms = std::env::var("LESAVKA_CLIPBOARD_DELAY_MS")
.ok()
.and_then(|v| v.parse::<u64>().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<String> {
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 { impl Drop for KeyboardAggregator {

View File

@ -136,3 +136,51 @@ pub fn is_modifier(key: KeyCode) -> Option<u8> {
_ => None, _ => 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,
}
}

View File

@ -154,3 +154,9 @@ impl MicrophoneCapture {
String::new() String::new()
} }
} }
impl Drop for MicrophoneCapture {
fn drop(&mut self) {
let _ = self.pipeline.set_state(gst::State::Null);
}
}

View File

@ -1,6 +1,6 @@
// client/src/input/mouse.rs // 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 std::time::{Duration, Instant};
use tokio::sync::broadcast::{self, Sender}; use tokio::sync::broadcast::{self, Sender};
use tracing::{debug, error, trace, warn}; use tracing::{debug, error, trace, warn};
@ -21,10 +21,41 @@ pub struct MouseAggregator {
dx: i8, dx: i8,
dy: i8, dy: i8,
wheel: i8, wheel: i8,
last_abs_x: Option<i32>,
last_abs_y: Option<i32>,
abs_scale: i32,
abs_jump_x: i32,
abs_jump_y: i32,
has_touch_state: bool,
touch_guarded: bool,
touch_active: bool,
} }
impl MouseAggregator { impl MouseAggregator {
pub fn new(dev: Device, dev_mode: bool, tx: Sender<MouseReport>) -> Self { pub fn new(dev: Device, dev_mode: bool, tx: Sender<MouseReport>) -> Self {
let abs_scale = std::env::var("LESAVKA_TOUCHPAD_SCALE")
.ok()
.and_then(|v| v.parse::<i32>().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 { Self {
dev, dev,
tx, tx,
@ -36,6 +67,14 @@ impl MouseAggregator {
dx: 0, dx: 0,
dy: 0, dy: 0,
wheel: 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_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_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_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() { 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(), 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) { fn flush(&mut self) {
if self.buttons == self.last_buttons && Instant::now() < self.next_send { if self.buttons == self.last_buttons && Instant::now() < self.next_send {
return; return;
@ -143,6 +264,27 @@ impl MouseAggregator {
self.buttons &= !(1 << bit) self.buttons &= !(1 << bit)
} }
} }
fn abs_jump_threshold(dev: &Device, codes: &[AbsoluteAxisCode], abs_scale: i32) -> i32 {
let mut range: Option<i32> = 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 { impl Drop for MouseAggregator {

View File

@ -7,5 +7,6 @@ pub mod handshake;
pub mod input; pub mod input;
pub mod layout; pub mod layout;
pub mod output; pub mod output;
pub mod paste;
pub use app::LesavkaClientApp; pub use app::LesavkaClientApp;

View File

@ -29,54 +29,40 @@ pub fn assign_rectangles(
match monitors.len() { match monitors.len() {
0 => return rects, // impossible, but keep compiler happy 0 => return rects, // impossible, but keep compiler happy
1 => { 1 => {
// One monitor: side-by-side layout // One monitor: side-by-side layout, full height
let m = &monitors[0].geometry; let m = &monitors[0].geometry;
let total_native_width: i32 = streams.iter().map(|(_, w, _)| *w).sum(); let count = streams.len().max(1) as i32;
let scale = f64::min( let base_w = m.width() / count;
m.width() as f64 / total_native_width as f64,
m.height() as f64 / streams[0].2 as f64,
);
debug!("one-monitor scale = {}", scale);
let mut x = m.x(); let mut x = m.x();
for (idx, &(_, w, h)) in streams.iter().enumerate() { for (idx, _stream) in streams.iter().enumerate() {
let ww = (w as f64 * scale).round() as i32; let w = if idx == streams.len() - 1 {
let hh = (h as f64 * scale).round() as i32; m.width() - base_w * (count - 1)
} else {
base_w
};
rects[idx] = Rect { rects[idx] = Rect {
x, x,
y: m.y(), y: m.y(),
w: ww, w,
h: hh, h: m.height(),
}; };
x += ww; x += w;
} }
} }
_ => { _ => {
// ≥2 monitors: map 1-to-1 until we run out // ≥2 monitors: map 1-to-1 until we run out, full screen per monitor
for (idx, stream) in streams.iter().enumerate() { for (idx, _stream) in streams.iter().enumerate() {
if idx >= monitors.len() { if idx >= monitors.len() {
break; break;
} }
let m = &monitors[idx]; let m = &monitors[idx];
let geom = m.geometry; 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 { rects[idx] = Rect {
x: xx, x: geom.x(),
y: yy, y: geom.y(),
w: ww, w: geom.width(),
h: hh, h: geom.height(),
}; };
} }
} }

View File

@ -70,10 +70,10 @@ impl MonitorWindow {
if let Ok(overlay) = sink_elem.dynamic_cast::<VideoOverlay>() { if let Ok(overlay) = sink_elem.dynamic_cast::<VideoOverlay>() {
if let Some(r) = rects.get(id as usize) { if let Some(r) = rects.get(id as usize) {
// 1. Tell glimagesink how to crop the texture in its own window // 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!( debug!(
"🔲 eye-{id} → render_rectangle({}, {}, {}, {})", "🔲 eye-{id} → render_rectangle({}, {}, {}, {})",
r.x, r.y, r.w, r.h 0, 0, r.w, r.h
); );
// 2. **Compositor-level** placement (Wayland only) // 2. **Compositor-level** placement (Wayland only)
@ -242,3 +242,9 @@ impl MonitorWindow {
let _ = self.src.push_buffer(buf); // ignore Eos/flushing 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);
}
}

73
client/src/paste.rs Normal file
View File

@ -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<PasteRequest> {
let max = std::env::var("LESAVKA_PASTE_MAX")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(4096);
let text = if text.chars().count() > max {
text.chars().take(max).collect::<String>()
} 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<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() / 2);
let chars: Vec<char> = 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)
}

View File

@ -11,6 +11,13 @@ message AudioPacket { uint32 id = 1; uint64 pts = 2; bytes data = 3; }
message ResetUsbReply { bool ok = 1; } // true = success 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 { message HandshakeSet {
bool camera = 1; bool camera = 1;
bool microphone = 2; bool microphone = 2;
@ -31,6 +38,7 @@ service Relay {
rpc StreamMicrophone (stream AudioPacket) returns (stream Empty); rpc StreamMicrophone (stream AudioPacket) returns (stream Empty);
rpc StreamCamera (stream VideoPacket) returns (stream Empty); rpc StreamCamera (stream VideoPacket) returns (stream Empty);
rpc PasteText (PasteRequest) returns (PasteReply);
rpc ResetUsb (Empty) returns (ResetUsbReply); rpc ResetUsb (Empty) returns (ResetUsbReply);
} }

49
docker/client.Dockerfile Normal file
View File

@ -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"]

38
docker/server.Dockerfile Normal file
View File

@ -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"]

View File

@ -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

View File

@ -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

31
scripts/ci/build-dist.sh Executable file
View File

@ -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}"

63
scripts/ci/build-images.sh Executable file
View File

@ -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

View File

@ -2,6 +2,12 @@
# scripts/daemon/lesavka-uvc.sh - launch UVC control helper as a standalone service # scripts/daemon/lesavka-uvc.sh - launch UVC control helper as a standalone service
set -euo pipefail 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} DEV=${LESAVKA_UVC_DEV:-/dev/v4l/by-path/platform-1000480000.usb-video-index0}
if [[ ! -e "$DEV" ]]; then if [[ ! -e "$DEV" ]]; then
DEV=/dev/video0 DEV=/dev/video0

View File

@ -3,20 +3,34 @@
set -euo pipefail set -euo pipefail
ORIG_USER=${SUDO_USER:-$(id -un)} 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) # 1. packages (Arch)
sudo pacman -Syq --needed --noconfirm \ sudo pacman -Syq --needed --noconfirm \
git rustup protobuf gcc clang evtest base-devel \ git rustup protobuf gcc clang evtest base-devel \
gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \ gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \
pipewire pipewire-pulse \ 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) ensure_yay() {
if ! command -v yay >/dev/null 2>&1; then 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 && 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' 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 fi
sudo -u "$ORIG_USER" yay -S --needed --noconfirm grpcurl-bin
# 1c. input access # 1c. input access
sudo usermod -aG input "$ORIG_USER" sudo usermod -aG input "$ORIG_USER"
@ -25,14 +39,19 @@ sudo usermod -aG input "$ORIG_USER"
sudo rustup default stable sudo rustup default stable
sudo -u "$ORIG_USER" 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) USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
SRC="$USER_HOME/.local/src/lesavka" if [[ -n ${REPO_ROOT:-} && -d $REPO_ROOT/.git ]]; then
sudo -u "$ORIG_USER" mkdir -p "$(dirname "$SRC")" SRC="$REPO_ROOT"
if [[ -d $SRC/.git ]]; then echo "==> 3. Using local repo at $SRC"
sudo -u "$ORIG_USER" git -C "$SRC" pull --ff-only
else else
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" sudo -u "$ORIG_USER" git clone "$PWD" "$SRC"
fi
fi fi
# 4. build # 4. build
@ -68,3 +87,6 @@ EOF
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now lesavka-client.service sudo systemctl enable --now lesavka-client.service
sudo systemctl restart lesavka-client || true 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)"

View File

@ -247,6 +247,7 @@ KillMode=process
TimeoutStopSec=10 TimeoutStopSec=10
StandardError=append:/tmp/lesavka-uvc.stderr StandardError=append:/tmp/lesavka-uvc.stderr
User=root User=root
EnvironmentFile=-/etc/lesavka/uvc.env
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -25,6 +25,8 @@ gstreamer-video = "0.23"
udev = "0.8" udev = "0.8"
prost-types = "0.13" prost-types = "0.13"
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] } chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
chacha20poly1305 = "0.10"
base64 = "0.22"
[build-dependencies] [build-dependencies]
prost-build = "0.13" prost-build = "0.13"

View File

@ -2,7 +2,6 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use anyhow::{Context, anyhow}; use anyhow::{Context, anyhow};
use chrono::Local;
use futures_util::Stream; use futures_util::Stream;
use gst::ElementFactory; use gst::ElementFactory;
use gst::MessageView::*; use gst::MessageView::*;
@ -10,7 +9,7 @@ use gst::prelude::*;
use gstreamer as gst; use gstreamer as gst;
use gstreamer_app as gst_app; use gstreamer_app as gst_app;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::Status; use tonic::Status;
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
@ -256,22 +255,55 @@ impl Voice {
let decodebin = gst::ElementFactory::make("decodebin") let decodebin = gst::ElementFactory::make("decodebin")
.build() .build()
.context("make decodebin")?; .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") let alsa_sink = gst::ElementFactory::make("alsasink")
.build() .build()
.context("make alsasink")?; .context("make alsasink")?;
alsa_sink.set_property("device", &alsa_dev); 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)?; appsrc.link(&decodebin)?;
gst::Element::link_many(&[&convert, &resample, &capsfilter, &alsa_sink])?;
/*------------ decodebin autolink ----------------*/ /*------------ 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| { decodebin.connect_pad_added(move |_db, pad| {
let sink_pad = sink_clone.static_pad("sink").unwrap(); if convert_sink.is_linked() {
if !sink_pad.is_linked() { return;
let _ = pad.link(&sink_pad);
} }
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 // underrun ≠ error just show a warning
@ -290,8 +322,6 @@ impl Voice {
} }
pub fn push(&mut self, pkt: &AudioPacket) { pub fn push(&mut self, pkt: &AudioPacket) {
use gst::prelude::*;
self.tap.feed(&pkt.data); self.tap.feed(&pkt.data);
let mut buf = gst::Buffer::from_slice(pkt.data.clone()); let mut buf = gst::Buffer::from_slice(pkt.data.clone());

View File

@ -311,9 +311,7 @@ impl UvcConfig {
max_packet max_packet
); );
} }
if let Some(cfg_max) = read_u32_file( if let Some(cfg_max) = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxpacket")) {
&format!("{CONFIGFS_UVC_BASE}/streaming_maxpacket"),
) {
if max_packet > cfg_max { if max_packet > cfg_max {
eprintln!( eprintln!(
"[lesavka-uvc] configfs maxpacket {}: clamp max_packet {} -> {}", "[lesavka-uvc] configfs maxpacket {}: clamp max_packet {} -> {}",
@ -820,12 +818,18 @@ fn read_u32_first(path: &str) -> Option<u32> {
} }
fn read_configfs_snapshot() -> Option<ConfigfsSnapshot> { fn read_configfs_snapshot() -> Option<ConfigfsSnapshot> {
let width = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/wWidth"))?; let width = read_u32_file(&format!(
let height = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/wHeight"))?; "{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/wWidth"
let default_interval = ))?;
read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/dwDefaultFrameInterval"))?; let height = read_u32_file(&format!(
let frame_interval = "{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/wHeight"
read_u32_first(&format!("{CONFIGFS_UVC_BASE}/streaming/mjpeg/m/720p/dwFrameInterval")) ))?;
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); .unwrap_or(0);
let maxpacket = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxpacket"))?; 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); let maxburst = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxburst")).unwrap_or(0);

View File

@ -70,9 +70,7 @@ pub fn current_camera_config() -> CameraConfig {
fn select_camera_config() -> CameraConfig { fn select_camera_config() -> CameraConfig {
let output_env = std::env::var("LESAVKA_CAM_OUTPUT").ok(); let output_env = std::env::var("LESAVKA_CAM_OUTPUT").ok();
let output_override = output_env let output_override = output_env.as_deref().and_then(parse_camera_output);
.as_deref()
.and_then(parse_camera_output);
let require_connected = output_override != Some(CameraOutput::Hdmi); let require_connected = output_override != Some(CameraOutput::Hdmi);
let hdmi = detect_hdmi_connector(require_connected); let hdmi = detect_hdmi_connector(require_connected);
@ -204,15 +202,12 @@ fn detect_hdmi_connector(require_connected: bool) -> Option<HdmiConnector> {
connectors.push((name, status, id)); connectors.push((name, status, id));
} }
let matches_preferred = |name: &str, preferred: &str| { let matches_preferred =
name == preferred || name.ends_with(preferred) |name: &str, preferred: &str| name == preferred || name.ends_with(preferred);
};
if let Some(pref) = preferred.as_deref() { if let Some(pref) = preferred.as_deref() {
for (name, status, id) in &connectors { for (name, status, id) in &connectors {
if matches_preferred(name, pref) if matches_preferred(name, pref) && (!require_connected || status == "connected") {
&& (!require_connected || status == "connected")
{
return Some(HdmiConnector { return Some(HdmiConnector {
name: name.clone(), name: name.clone(),
id: *id, id: *id,

View File

@ -120,7 +120,10 @@ impl UsbGadget {
match Self::state(&ctrl) { match Self::state(&ctrl) {
Ok(state) Ok(state)
if !force_cycle if !force_cycle
&& matches!(state.as_str(), "configured" | "addressed" | "default" | "suspended") => && matches!(
state.as_str(),
"configured" | "addressed" | "default" | "suspended"
) =>
{ {
warn!( warn!(
"🔒 refusing gadget cycle while host attached (state={state}); set LESAVKA_GADGET_FORCE_CYCLE=1 to override" "🔒 refusing gadget cycle while host attached (state={state}); set LESAVKA_GADGET_FORCE_CYCLE=1 to override"

View File

@ -1,11 +1,11 @@
// ─── server/src/handshake.rs ─────────────────────────────────────────────── // ─── server/src/handshake.rs ───────────────────────────────────────────────
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
use crate::camera;
use lesavka_common::lesavka::{ use lesavka_common::lesavka::{
Empty, HandshakeSet, Empty, HandshakeSet,
handshake_server::{Handshake, HandshakeServer}, handshake_server::{Handshake, HandshakeServer},
}; };
use crate::camera;
pub struct HandshakeSvc; pub struct HandshakeSvc;

View File

@ -4,4 +4,5 @@ pub mod audio;
pub mod camera; pub mod camera;
pub mod gadget; pub mod gadget;
pub mod handshake; pub mod handshake;
pub mod paste;
pub mod video; pub mod video;

View File

@ -19,11 +19,12 @@ use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*}; use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*};
use lesavka_common::lesavka::{ use lesavka_common::lesavka::{
AudioPacket, Empty, KeyboardReport, MonitorRequest, MouseReport, ResetUsbReply, VideoPacket, AudioPacket, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply, PasteRequest,
ResetUsbReply, VideoPacket,
relay_server::{Relay, RelayServer}, 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 ────────────────*/ /*──────────────── constants ────────────────*/
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
@ -489,6 +490,21 @@ impl Relay for Handler {
Ok(Response::new(Box::pin(s))) Ok(Response::new(Box::pin(s)))
} }
async fn paste_text(&self, req: Request<PasteRequest>) -> Result<Response<PasteReply>, 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 ────────────*/ /*────────────── USB-reset RPC ────────────*/
async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> { async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> {
info!("🔴 explicit ResetUsb() called"); info!("🔴 explicit ResetUsb() called");

132
server/src/paste.rs Normal file
View File

@ -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<String> {
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<File>, text: &str) -> Result<()> {
let max = std::env::var("LESAVKA_PASTE_MAX")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(4096);
let delay_ms = std::env::var("LESAVKA_PASTE_DELAY_MS")
.ok()
.and_then(|v| v.parse::<u64>().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<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() / 2);
let chars: Vec<char> = 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,
}
}

View File

@ -413,7 +413,6 @@ impl WebcamSink {
tracing::warn!(target:"lesavka_server::video", %err, "📸⚠️ appsrc push failed"); tracing::warn!(target:"lesavka_server::video", %err, "📸⚠️ appsrc push failed");
} }
} }
} }
pub struct HdmiSink { pub struct HdmiSink {