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"
|
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"
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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_FORMAT")
|
&& (std::env::var("LESAVKA_CAM_MJPG").is_ok()
|
||||||
.ok()
|
|| std::env::var("LESAVKA_CAM_FORMAT")
|
||||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "mjpg" | "mjpeg" | "jpeg"))
|
.ok()
|
||||||
.unwrap_or(false);
|
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "mjpg" | "mjpeg" | "jpeg"))
|
||||||
|
.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 down‑cast");
|
.expect("appsink down‑cast");
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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!!! 🔓🕊️");
|
||||||
}
|
}
|
||||||
for k in &mut self.keyboards {
|
if self.released {
|
||||||
k.set_grab(self.released);
|
// switching to remote control
|
||||||
k.set_send(self.released);
|
for k in &mut self.keyboards {
|
||||||
|
k.reset_state();
|
||||||
|
k.set_send(true);
|
||||||
|
k.set_grab(true);
|
||||||
|
}
|
||||||
|
for m in &mut self.mice {
|
||||||
|
m.reset_state();
|
||||||
|
m.set_send(true);
|
||||||
|
m.set_grab(true);
|
||||||
|
}
|
||||||
|
self.released = false;
|
||||||
|
self.pending_release = false;
|
||||||
|
} else {
|
||||||
|
// switching to local control: stop sending, keep grab until chord released
|
||||||
|
for k in &mut self.keyboards {
|
||||||
|
k.send_empty_report();
|
||||||
|
k.set_send(false);
|
||||||
|
}
|
||||||
|
for m in &mut self.mice {
|
||||||
|
m.reset_state();
|
||||||
|
m.set_send(false);
|
||||||
|
}
|
||||||
|
self.pending_release = true;
|
||||||
|
self.capture_pending_keys();
|
||||||
}
|
}
|
||||||
for m in &mut self.mice {
|
}
|
||||||
m.set_grab(self.released);
|
|
||||||
m.set_send(self.released);
|
fn capture_pending_keys(&mut self) {
|
||||||
|
self.pending_keys.clear();
|
||||||
|
for k in &self.keyboards {
|
||||||
|
for key in k.pressed_keys_snapshot() {
|
||||||
|
self.pending_keys.insert(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.released = !self.released;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,7 +272,7 @@ fn classify_device(dev: &Device) -> DeviceKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse logic
|
// Mouse logic (relative)
|
||||||
if evbits.contains(EventType::RELATIVE) {
|
if 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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
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 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
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
|
# 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
|
||||||
|
|||||||
@ -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
|
||||||
cd yay && makepkg -si --noconfirm'
|
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
|
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
|
||||||
sudo -u "$ORIG_USER" git clone "$PWD" "$SRC"
|
SRC="$USER_HOME/.local/src/lesavka"
|
||||||
|
sudo -u "$ORIG_USER" mkdir -p "$(dirname "$SRC")"
|
||||||
|
if [[ -d $SRC/.git ]]; then
|
||||||
|
sudo -u "$ORIG_USER" git -C "$SRC" pull --ff-only
|
||||||
|
else
|
||||||
|
sudo -u "$ORIG_USER" git clone "$PWD" "$SRC"
|
||||||
|
fi
|
||||||
fi
|
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)"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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,13 +818,19 @@ 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"))
|
))?;
|
||||||
.unwrap_or(0);
|
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 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);
|
||||||
Some(ConfigfsSnapshot {
|
Some(ConfigfsSnapshot {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
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");
|
tracing::warn!(target:"lesavka_server::video", %err, "📸⚠️ appsrc push failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct HdmiSink {
|
pub struct HdmiSink {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user