master: ship webcam hdmi path + secure paste + deploy tooling
This commit is contained in:
parent
4f898ddee7
commit
eaa03924ed
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
.git
|
||||
**/target
|
||||
**/dist
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/.cache
|
||||
**/.DS_Store
|
||||
89
Jenkinsfile
vendored
Normal file
89
Jenkinsfile
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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<KeyboardReport>,
|
||||
mou_tx: broadcast::Sender<MouseReport>,
|
||||
paste_rx: Option<mpsc::UnboundedReceiver<String>>,
|
||||
}
|
||||
|
||||
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<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 ───────────────*/
|
||||
async fn stream_loop_keyboard(&self, ep: Channel) {
|
||||
loop {
|
||||
|
||||
@ -35,24 +35,44 @@ pub struct CameraCapture {
|
||||
}
|
||||
|
||||
impl CameraCapture {
|
||||
pub fn new(
|
||||
device_fragment: Option<&str>,
|
||||
cfg: Option<CameraConfig>,
|
||||
) -> anyhow::Result<Self> {
|
||||
pub fn new(device_fragment: Option<&str>, cfg: Option<CameraConfig>) -> anyhow::Result<Self> {
|
||||
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()
|
||||
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);
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<KeyboardReport>,
|
||||
@ -19,6 +21,10 @@ pub struct InputAggregator {
|
||||
dev_mode: bool,
|
||||
released: bool,
|
||||
magic_active: bool,
|
||||
pending_release: bool,
|
||||
pending_kill: bool,
|
||||
pending_keys: HashSet<KeyCode>,
|
||||
paste_tx: Option<UnboundedSender<String>>,
|
||||
keyboards: Vec<KeyboardAggregator>,
|
||||
mice: Vec<MouseAggregator>,
|
||||
}
|
||||
@ -28,6 +34,7 @@ impl InputAggregator {
|
||||
dev_mode: bool,
|
||||
kbd_tx: Sender<KeyboardReport>,
|
||||
mou_tx: Sender<MouseReport>,
|
||||
paste_tx: Option<UnboundedSender<String>>,
|
||||
) -> 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!!! 🔓🕊️");
|
||||
}
|
||||
if self.released {
|
||||
// switching to remote control
|
||||
for k in &mut self.keyboards {
|
||||
k.set_grab(self.released);
|
||||
k.set_send(self.released);
|
||||
k.reset_state();
|
||||
k.set_send(true);
|
||||
k.set_grab(true);
|
||||
}
|
||||
for m in &mut self.mice {
|
||||
m.set_grab(self.released);
|
||||
m.set_send(self.released);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -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<KeyboardReport>,
|
||||
dev_mode: bool,
|
||||
sending_disabled: bool,
|
||||
paste_enabled: bool,
|
||||
paste_rpc_enabled: bool,
|
||||
paste_tx: Option<UnboundedSender<String>>,
|
||||
pressed_keys: HashSet<KeyCode>,
|
||||
}
|
||||
|
||||
/*───────── 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<KeyboardReport>) -> Self {
|
||||
pub fn new(
|
||||
dev: Device,
|
||||
dev_mode: bool,
|
||||
tx: Sender<KeyboardReport>,
|
||||
paste_tx: Option<UnboundedSender<String>>,
|
||||
) -> 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<InputEvent> = 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<KeyCode> {
|
||||
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::<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 {
|
||||
|
||||
@ -136,3 +136,51 @@ pub fn is_modifier(key: KeyCode) -> Option<u8> {
|
||||
_ => 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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,3 +154,9 @@ impl MicrophoneCapture {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MicrophoneCapture {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.pipeline.set_state(gst::State::Null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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 {
|
||||
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 {
|
||||
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<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 {
|
||||
|
||||
@ -7,5 +7,6 @@ pub mod handshake;
|
||||
pub mod input;
|
||||
pub mod layout;
|
||||
pub mod output;
|
||||
pub mod paste;
|
||||
|
||||
pub use app::LesavkaClientApp;
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,10 +70,10 @@ impl MonitorWindow {
|
||||
if let Ok(overlay) = sink_elem.dynamic_cast::<VideoOverlay>() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
73
client/src/paste.rs
Normal file
73
client/src/paste.rs
Normal 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)
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
49
docker/client.Dockerfile
Normal file
49
docker/client.Dockerfile
Normal 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
38
docker/server.Dockerfile
Normal 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"]
|
||||
32
scripts/ansible/deploy-client.yml
Normal file
32
scripts/ansible/deploy-client.yml
Normal 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
|
||||
32
scripts/ansible/deploy-server.yml
Normal file
32
scripts/ansible/deploy-server.yml
Normal 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
31
scripts/ci/build-dist.sh
Executable 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
63
scripts/ci/build-images.sh
Executable 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
|
||||
@ -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
|
||||
|
||||
@ -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 &&
|
||||
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
|
||||
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)"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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,12 +818,18 @@ fn read_u32_first(path: &str) -> Option<u32> {
|
||||
}
|
||||
|
||||
fn read_configfs_snapshot() -> Option<ConfigfsSnapshot> {
|
||||
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"))
|
||||
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);
|
||||
|
||||
@ -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<HdmiConnector> {
|
||||
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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -4,4 +4,5 @@ pub mod audio;
|
||||
pub mod camera;
|
||||
pub mod gadget;
|
||||
pub mod handshake;
|
||||
pub mod paste;
|
||||
pub mod video;
|
||||
|
||||
@ -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<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 ────────────*/
|
||||
async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> {
|
||||
info!("🔴 explicit ResetUsb() called");
|
||||
|
||||
132
server/src/paste.rs
Normal file
132
server/src/paste.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -413,7 +413,6 @@ impl WebcamSink {
|
||||
tracing::warn!(target:"lesavka_server::video", %err, "📸⚠️ appsrc push failed");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pub struct HdmiSink {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user