launcher: stabilize relay controls and paste handling
This commit is contained in:
parent
3897239ef4
commit
7d4754ba31
@ -542,6 +542,18 @@ fn focus_launcher_on_local_if_enabled() {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let focus_signal_path = std::env::var("LESAVKA_LAUNCHER_FOCUS_SIGNAL")
|
||||
.unwrap_or_else(|_| "/tmp/lesavka-launcher-focus.signal".to_string());
|
||||
let _ = std::fs::write(
|
||||
focus_signal_path,
|
||||
format!(
|
||||
"{}\n",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default()
|
||||
),
|
||||
);
|
||||
let title = std::env::var("LESAVKA_LAUNCHER_WINDOW_TITLE")
|
||||
.unwrap_or_else(|_| "Lesavka Launcher".to_string());
|
||||
let _ = std::process::Command::new("wmctrl")
|
||||
|
||||
@ -12,7 +12,8 @@ use tracing::{debug, error, trace};
|
||||
|
||||
use lesavka_common::lesavka::KeyboardReport;
|
||||
|
||||
use super::keymap::{char_to_usage, is_modifier, keycode_to_usage};
|
||||
use super::keymap::{is_modifier, keycode_to_usage};
|
||||
use lesavka_common::hid::append_char_reports;
|
||||
|
||||
pub struct KeyboardAggregator {
|
||||
dev: Device,
|
||||
@ -371,9 +372,11 @@ impl KeyboardAggregator {
|
||||
.unwrap_or(4096);
|
||||
|
||||
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]);
|
||||
let mut reports = Vec::with_capacity(4);
|
||||
if append_char_reports(&mut reports, c) {
|
||||
for report in reports {
|
||||
self.send_report(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -404,11 +407,13 @@ impl KeyboardAggregator {
|
||||
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);
|
||||
let mut reports = Vec::with_capacity(4);
|
||||
if append_char_reports(&mut reports, c) {
|
||||
for report in reports {
|
||||
self.send_report(report);
|
||||
if delay_ms > 0 {
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
176
client/src/launcher/clipboard.rs
Normal file
176
client/src/launcher/clipboard.rs
Normal file
@ -0,0 +1,176 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use lesavka_common::{
|
||||
hid::append_char_reports,
|
||||
lesavka::KeyboardReport,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
use {
|
||||
crate::paste,
|
||||
async_stream::stream,
|
||||
lesavka_common::lesavka::relay_client::RelayClient,
|
||||
std::process::Command,
|
||||
tokio::runtime::Builder as RuntimeBuilder,
|
||||
tonic::{Request, transport::Channel},
|
||||
};
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
/// Deliver the local clipboard to the remote side, preferring the encrypted
|
||||
/// paste RPC and falling back to direct HID keyboard reports when the shared
|
||||
/// key is unavailable.
|
||||
pub fn send_clipboard_to_remote(server_addr: &str) -> Result<String> {
|
||||
let text = read_clipboard_text().ok_or_else(|| anyhow!("clipboard is empty or unavailable"))?;
|
||||
match send_clipboard_via_rpc(server_addr, &text) {
|
||||
Ok(()) => Ok("Clipboard delivered to remote".to_string()),
|
||||
Err(rpc_err) => match send_clipboard_via_hid(server_addr, &text) {
|
||||
Ok(()) => Ok(format!("Clipboard delivered via HID fallback ({rpc_err})")),
|
||||
Err(hid_err) => Err(anyhow!("rpc failed: {rpc_err}; hid fallback failed: {hid_err}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
/// Use the shared-key paste RPC when both the launcher and relay host are
|
||||
/// configured for encrypted clipboard delivery.
|
||||
fn send_clipboard_via_rpc(server_addr: &str, text: &str) -> Result<()> {
|
||||
let req = paste::build_paste_request(text)?;
|
||||
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
|
||||
rt.block_on(async {
|
||||
let channel = Channel::from_shared(server_addr.to_string())?
|
||||
.connect()
|
||||
.await?;
|
||||
let mut cli = RelayClient::new(channel);
|
||||
let reply = cli.paste_text(Request::new(req)).await?;
|
||||
if reply.get_ref().ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("server rejected paste: {}", reply.get_ref().error))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
/// Fall back to keyboard HID reports so launcher-driven paste still works
|
||||
/// against relay hosts that have not been given `LESAVKA_PASTE_KEY`.
|
||||
fn send_clipboard_via_hid(server_addr: &str, text: &str) -> Result<()> {
|
||||
let reports = build_hid_paste_reports(text)?;
|
||||
let delay = clipboard_hid_delay();
|
||||
let report_count = reports.len();
|
||||
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
|
||||
rt.block_on(async {
|
||||
let channel = Channel::from_shared(server_addr.to_string())?
|
||||
.connect()
|
||||
.await?;
|
||||
let mut cli = RelayClient::new(channel);
|
||||
let outbound = stream! {
|
||||
for report in reports {
|
||||
yield report;
|
||||
if !delay.is_zero() {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut resp = cli.stream_keyboard(Request::new(outbound)).await?;
|
||||
let mut echoed = 0usize;
|
||||
while let Some(item) = resp.get_mut().message().await.transpose() {
|
||||
item?;
|
||||
echoed += 1;
|
||||
if echoed >= report_count {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert clipboard text into press/release HID reports that match the
|
||||
/// server-side keyboard gadget mapping.
|
||||
fn build_hid_paste_reports(text: &str) -> Result<Vec<KeyboardReport>> {
|
||||
let mut raw_reports = Vec::with_capacity(text.len() * 4);
|
||||
let mut unsupported = 0usize;
|
||||
for ch in text.chars() {
|
||||
if !append_char_reports(&mut raw_reports, ch) {
|
||||
unsupported += 1;
|
||||
}
|
||||
}
|
||||
if unsupported > 0 {
|
||||
return Err(anyhow!(
|
||||
"clipboard contains {unsupported} unsupported character(s) for HID fallback"
|
||||
));
|
||||
}
|
||||
Ok(raw_reports
|
||||
.into_iter()
|
||||
.map(|data| KeyboardReport { data: data.to_vec() })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Keep launcher-driven HID paste slightly slower than live typing so login
|
||||
/// prompts have more time to digest each character.
|
||||
fn clipboard_hid_delay() -> Duration {
|
||||
let delay_ms = std::env::var("LESAVKA_CLIPBOARD_DELAY_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(18);
|
||||
Duration::from_millis(delay_ms)
|
||||
}
|
||||
|
||||
/// Read the local clipboard and drop trailing file-copy newlines so password
|
||||
/// pastes do not accidentally submit an extra Enter.
|
||||
#[cfg(not(coverage))]
|
||||
fn read_clipboard_text() -> Option<String> {
|
||||
if let Ok(out) = Command::new("sh")
|
||||
.arg("-lc")
|
||||
.arg(std::env::var("LESAVKA_CLIPBOARD_CMD").unwrap_or_else(
|
||||
|_| "wl-paste --no-newline --type text/plain || xclip -selection clipboard -o || xsel -b -o".to_string(),
|
||||
))
|
||||
.output()
|
||||
&& out.status.success()
|
||||
{
|
||||
let text = trim_clipboard_text(String::from_utf8_lossy(&out.stdout).to_string());
|
||||
if !text.is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Trim trailing clipboard newlines so pasted passwords do not gain a
|
||||
/// synthetic submit keystroke.
|
||||
fn trim_clipboard_text(text: String) -> String {
|
||||
text.trim_end_matches(['\r', '\n']).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_hid_paste_reports, clipboard_hid_delay, trim_clipboard_text};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn trim_clipboard_text_strips_trailing_newlines() {
|
||||
assert_eq!(trim_clipboard_text("secret\n".to_string()), "secret");
|
||||
assert_eq!(trim_clipboard_text("secret\r\n".to_string()), "secret");
|
||||
assert_eq!(trim_clipboard_text("secret".to_string()), "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hid_paste_reports_emits_press_and_release_pairs() {
|
||||
let reports = build_hid_paste_reports("Az").expect("hid reports");
|
||||
assert_eq!(reports.len(), 6);
|
||||
assert_eq!(reports[0].data.len(), 8);
|
||||
assert_eq!(reports[0].data, vec![0x02, 0, 0, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(reports[3].data, vec![0; 8]);
|
||||
assert_eq!(reports[5].data, vec![0; 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hid_paste_reports_rejects_unsupported_chars() {
|
||||
let err = build_hid_paste_reports("snowman \u{2603}").expect_err("unsupported");
|
||||
assert!(format!("{err:#}").contains("unsupported character"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_hid_delay_has_stable_default() {
|
||||
assert_eq!(clipboard_hid_delay(), Duration::from_millis(18));
|
||||
}
|
||||
}
|
||||
@ -147,7 +147,7 @@ mod tests {
|
||||
assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb"));
|
||||
assert_eq!(report.recent_samples.len(), 1);
|
||||
assert_eq!(report.notes, vec!["first note".to_string()]);
|
||||
assert!(report.status.contains("mode=local"));
|
||||
assert!(report.status.contains("mode=remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -2,11 +2,12 @@ pub mod devices;
|
||||
pub mod diagnostics;
|
||||
pub mod state;
|
||||
|
||||
mod clipboard;
|
||||
#[cfg(not(coverage))]
|
||||
mod preview;
|
||||
mod ui;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use crate::app_support::DEFAULT_SERVER_ADDR;
|
||||
@ -14,6 +15,9 @@ use crate::app_support::DEFAULT_SERVER_ADDR;
|
||||
pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command};
|
||||
pub use state::{DeviceSelection, InputRouting, LauncherState, ViewMode};
|
||||
|
||||
pub const LAUNCHER_FOCUS_SIGNAL_ENV: &str = "LESAVKA_LAUNCHER_FOCUS_SIGNAL";
|
||||
pub const DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH: &str = "/tmp/lesavka-launcher-focus.signal";
|
||||
|
||||
pub fn maybe_run_launcher(args: &[String]) -> Result<bool> {
|
||||
if should_run_launcher(args) {
|
||||
let server_addr = resolve_server_addr(args);
|
||||
@ -53,6 +57,12 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
|
||||
envs
|
||||
}
|
||||
|
||||
pub fn launcher_focus_signal_path() -> PathBuf {
|
||||
std::env::var(LAUNCHER_FOCUS_SIGNAL_ENV)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from(DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH))
|
||||
}
|
||||
|
||||
fn resolve_server_addr(args: &[String]) -> String {
|
||||
for window in args.windows(2) {
|
||||
if window[0] == "--server" {
|
||||
|
||||
@ -11,6 +11,8 @@ use gtk::{gdk, glib};
|
||||
#[cfg(not(coverage))]
|
||||
use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient};
|
||||
#[cfg(not(coverage))]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(not(coverage))]
|
||||
use std::sync::{Arc, Mutex};
|
||||
#[cfg(not(coverage))]
|
||||
use std::time::Duration;
|
||||
@ -51,31 +53,45 @@ impl LauncherPreview {
|
||||
feed.install_on_picture(picture, status_label);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool) {
|
||||
for feed in &self.feeds {
|
||||
feed.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
struct PreviewFeed {
|
||||
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
impl PreviewFeed {
|
||||
fn spawn(server_addr: String, monitor_id: u32) -> Result<Self> {
|
||||
let latest = Arc::new(Mutex::new(None));
|
||||
let enabled = Arc::new(AtomicBool::new(true));
|
||||
let store = Arc::clone(&latest);
|
||||
let enabled_flag = Arc::clone(&enabled);
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = run_preview_feed(server_addr, monitor_id, store) {
|
||||
if let Err(err) = run_preview_feed(server_addr, monitor_id, store, enabled_flag) {
|
||||
warn!(monitor_id, ?err, "launcher preview feed exited");
|
||||
}
|
||||
});
|
||||
Ok(Self { latest })
|
||||
Ok(Self { latest, enabled })
|
||||
}
|
||||
|
||||
fn install_on_picture(&self, picture: >k::Picture, status_label: >k::Label) {
|
||||
let picture = picture.clone();
|
||||
let status_label = status_label.clone();
|
||||
let latest = Arc::clone(&self.latest);
|
||||
let enabled = Arc::clone(&self.enabled);
|
||||
glib::timeout_add_local(Duration::from_millis(120), move || {
|
||||
if !enabled.load(Ordering::Relaxed) {
|
||||
status_label.set_text("Paused for pop-out windows");
|
||||
return glib::ControlFlow::Continue;
|
||||
}
|
||||
let next = latest.lock().ok().and_then(|mut slot| slot.take());
|
||||
if let Some(frame) = next {
|
||||
let bytes = glib::Bytes::from_owned(frame.rgba);
|
||||
@ -92,6 +108,15 @@ impl PreviewFeed {
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
fn set_enabled(&self, enabled: bool) {
|
||||
self.enabled.store(enabled, Ordering::Relaxed);
|
||||
if !enabled {
|
||||
if let Ok(mut slot) = self.latest.lock() {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
@ -107,6 +132,7 @@ fn run_preview_feed(
|
||||
server_addr: String,
|
||||
monitor_id: u32,
|
||||
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let (pipeline, appsrc, appsink) = build_preview_pipeline()?;
|
||||
pipeline
|
||||
@ -136,6 +162,10 @@ fn run_preview_feed(
|
||||
|
||||
let _ = rt.block_on(async move {
|
||||
loop {
|
||||
if !enabled.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
continue;
|
||||
}
|
||||
let channel = match Channel::from_shared(server_addr.clone()) {
|
||||
Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await {
|
||||
Ok(channel) => channel,
|
||||
@ -160,6 +190,9 @@ fn run_preview_feed(
|
||||
Ok(mut stream) => {
|
||||
debug!(monitor_id, "launcher preview connected");
|
||||
while let Some(item) = stream.get_mut().message().await.transpose() {
|
||||
if !enabled.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
match item {
|
||||
Ok(pkt) => push_preview_packet(&appsrc, pkt),
|
||||
Err(err) => {
|
||||
|
||||
@ -51,7 +51,7 @@ pub struct LauncherState {
|
||||
impl Default for LauncherState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
routing: InputRouting::Local,
|
||||
routing: InputRouting::Remote,
|
||||
view_mode: ViewMode::Unified,
|
||||
devices: DeviceSelection::default(),
|
||||
remote_active: false,
|
||||
@ -160,9 +160,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_pick_local_unified_and_inactive_session() {
|
||||
fn defaults_pick_remote_unified_and_inactive_session() {
|
||||
let state = LauncherState::new();
|
||||
assert_eq!(state.routing, InputRouting::Local);
|
||||
assert_eq!(state.routing, InputRouting::Remote);
|
||||
assert_eq!(state.view_mode, ViewMode::Unified);
|
||||
assert!(!state.remote_active);
|
||||
assert!(state.devices.camera.is_none());
|
||||
|
||||
@ -1,20 +1,22 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::Result;
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
use {
|
||||
super::clipboard::send_clipboard_to_remote,
|
||||
super::devices::DeviceCatalog,
|
||||
super::diagnostics::quality_probe_command,
|
||||
super::launcher_focus_signal_path,
|
||||
super::preview::LauncherPreview,
|
||||
super::runtime_env_vars,
|
||||
super::state::{InputRouting, LauncherState, ViewMode},
|
||||
crate::paste,
|
||||
super::LAUNCHER_FOCUS_SIGNAL_ENV,
|
||||
gtk::prelude::*,
|
||||
lesavka_common::lesavka::relay_client::RelayClient,
|
||||
gtk::glib,
|
||||
std::cell::RefCell,
|
||||
std::path::Path,
|
||||
std::process::{Child, Command},
|
||||
std::rc::Rc,
|
||||
tokio::runtime::Builder as RuntimeBuilder,
|
||||
tonic::{Request, transport::Channel},
|
||||
std::time::Duration,
|
||||
};
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
@ -27,6 +29,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
state.borrow_mut().apply_catalog_defaults(&catalog);
|
||||
let child_proc = Rc::new(RefCell::new(None::<Child>));
|
||||
let server_addr = Rc::new(server_addr);
|
||||
let focus_signal_path = Rc::new(launcher_focus_signal_path());
|
||||
let _ = std::fs::remove_file(focus_signal_path.as_path());
|
||||
|
||||
{
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
@ -43,6 +47,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let state = Rc::clone(&state);
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let server_addr = Rc::clone(&server_addr);
|
||||
let focus_signal_path = Rc::clone(&focus_signal_path);
|
||||
|
||||
app.connect_activate(move |app| {
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
@ -191,27 +196,83 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
root.append(&probe_hint);
|
||||
|
||||
let note = gtk::Label::new(Some(
|
||||
"The live preview stays in this launcher by default so you can watch both displays before handing control over. Start Relay keeps the preview here in unified mode, Pop Out Windows switches back to external video windows, and Use Remote Inputs hands keyboard and mouse to the remote side.",
|
||||
"The live preview stays in this launcher by default so you can watch both displays before handing control over. Start Relay now starts in remote control by default, Pop Out Windows pauses the preview and moves you back to the external video windows, and pressing the swap key returns local control and re-focuses this launcher.",
|
||||
));
|
||||
note.set_wrap(true);
|
||||
note.set_halign(gtk::Align::Start);
|
||||
root.append(¬e);
|
||||
|
||||
match LauncherPreview::new(server_addr.as_ref().to_string()) {
|
||||
let preview = match LauncherPreview::new(server_addr.as_ref().to_string()) {
|
||||
Ok(preview) => {
|
||||
let preview = Rc::new(preview);
|
||||
preview.install_on_picture(0, &left_picture, &left_status);
|
||||
preview.install_on_picture(1, &right_picture, &right_status);
|
||||
Some(preview)
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = format!("Preview unavailable: {err}");
|
||||
left_status.set_text(&msg);
|
||||
right_status.set_text(&msg);
|
||||
None
|
||||
}
|
||||
};
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
|
||||
{
|
||||
let window = window.clone();
|
||||
let preview_frame = preview_frame.clone();
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let focus_signal_path = Rc::clone(&focus_signal_path);
|
||||
let routing_switch = routing_switch.clone();
|
||||
let view_toggle_button = view_toggle_button.clone();
|
||||
let input_toggle_button = input_toggle_button.clone();
|
||||
let last_focus_marker =
|
||||
Rc::new(RefCell::new(focus_signal_marker(focus_signal_path.as_path())));
|
||||
let last_focus_marker_handle = Rc::clone(&last_focus_marker);
|
||||
let status_label = status_label.clone();
|
||||
let state = Rc::clone(&state);
|
||||
let preview = preview.clone();
|
||||
glib::timeout_add_local(Duration::from_millis(200), move || {
|
||||
let next_marker = focus_signal_marker(focus_signal_path.as_path());
|
||||
let mut last_marker = last_focus_marker_handle.borrow_mut();
|
||||
if next_marker > *last_marker {
|
||||
*last_marker = next_marker;
|
||||
{
|
||||
let mut state = state.borrow_mut();
|
||||
state.set_routing(InputRouting::Local);
|
||||
routing_switch.set_active(false);
|
||||
sync_toggle_button_labels(
|
||||
&state,
|
||||
&view_toggle_button,
|
||||
&input_toggle_button,
|
||||
);
|
||||
}
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
status_label.set_text("Local control restored - launcher focused");
|
||||
window.present();
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let state = Rc::clone(&state);
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let window = window.clone();
|
||||
let preview_frame = preview_frame.clone();
|
||||
let preview = preview.clone();
|
||||
let status_label = status_label.clone();
|
||||
let routing_switch = routing_switch.clone();
|
||||
let view_combo = view_combo.clone();
|
||||
@ -259,12 +320,32 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
&server_entry,
|
||||
server_addr.as_ref(),
|
||||
&toggle_key_combo,
|
||||
preview.as_deref(),
|
||||
);
|
||||
|
||||
match spawn_result {
|
||||
Ok(()) => status_label.set_text(&format!("Started: {}", state.borrow().status_line())),
|
||||
Ok(()) => {
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
if matches!(state.borrow().view_mode, ViewMode::Breakout) {
|
||||
queue_breakout_window_surface(&window);
|
||||
}
|
||||
status_label.set_text(&format!("Started: {}", state.borrow().status_line()));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = state.borrow_mut().stop_remote();
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
status_label.set_text(&format!("Start failed: {err}"));
|
||||
}
|
||||
}
|
||||
@ -274,10 +355,20 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
{
|
||||
let state = Rc::clone(&state);
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let window = window.clone();
|
||||
let preview_frame = preview_frame.clone();
|
||||
let preview = preview.clone();
|
||||
let status_label = status_label.clone();
|
||||
stop_button.connect_clicked(move |_| {
|
||||
stop_child_process(&child_proc);
|
||||
let _ = state.borrow_mut().stop_remote();
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
status_label.set_text("Relay ended");
|
||||
});
|
||||
}
|
||||
@ -285,6 +376,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
{
|
||||
let state = Rc::clone(&state);
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let window = window.clone();
|
||||
let preview_frame = preview_frame.clone();
|
||||
let preview = preview.clone();
|
||||
let status_label = status_label.clone();
|
||||
let view_combo = view_combo.clone();
|
||||
let input_toggle_button = input_toggle_button.clone();
|
||||
@ -313,16 +407,43 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
&server_entry,
|
||||
server_addr.as_ref(),
|
||||
&toggle_key_combo,
|
||||
preview.as_deref(),
|
||||
);
|
||||
match spawn_result {
|
||||
Ok(()) => status_label
|
||||
.set_text(&format!("View switched live: {}", state.borrow().status_line())),
|
||||
Ok(()) => {
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
if matches!(state.borrow().view_mode, ViewMode::Breakout) {
|
||||
queue_breakout_window_surface(&window);
|
||||
}
|
||||
status_label
|
||||
.set_text(&format!("View switched live: {}", state.borrow().status_line()));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = state.borrow_mut().stop_remote();
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
status_label.set_text(&format!("View switch failed: {err}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
status_label.set_text(&format!("View ready: {}", state.borrow().status_line()));
|
||||
}
|
||||
});
|
||||
@ -331,6 +452,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
{
|
||||
let state = Rc::clone(&state);
|
||||
let child_proc = Rc::clone(&child_proc);
|
||||
let window = window.clone();
|
||||
let preview_frame = preview_frame.clone();
|
||||
let preview = preview.clone();
|
||||
let status_label = status_label.clone();
|
||||
let routing_switch = routing_switch.clone();
|
||||
let input_toggle_button = input_toggle_button.clone();
|
||||
@ -359,18 +483,45 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
&server_entry,
|
||||
server_addr.as_ref(),
|
||||
&toggle_key_combo,
|
||||
preview.as_deref(),
|
||||
);
|
||||
match spawn_result {
|
||||
Ok(()) => status_label.set_text(&format!(
|
||||
"Input mode switched live: {}",
|
||||
state.borrow().status_line()
|
||||
)),
|
||||
Ok(()) => {
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
if matches!(state.borrow().view_mode, ViewMode::Breakout) {
|
||||
queue_breakout_window_surface(&window);
|
||||
}
|
||||
status_label.set_text(&format!(
|
||||
"Input mode switched live: {}",
|
||||
state.borrow().status_line()
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = state.borrow_mut().stop_remote();
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
status_label.set_text(&format!("Input switch failed: {err}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sync_preview_runtime_state(
|
||||
&window,
|
||||
&preview_frame,
|
||||
preview.as_deref(),
|
||||
&state.borrow(),
|
||||
child_proc.borrow().is_some(),
|
||||
);
|
||||
status_label.set_text(&format!("Input ready: {}", state.borrow().status_line()));
|
||||
}
|
||||
});
|
||||
@ -383,14 +534,36 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
||||
let server_addr = Rc::clone(&server_addr);
|
||||
clipboard_button.connect_clicked(move |_| {
|
||||
if child_proc.borrow().is_none() {
|
||||
status_label.set_text("Start Session before sending clipboard");
|
||||
status_label.set_text("Start Relay before sending clipboard");
|
||||
return;
|
||||
}
|
||||
let server_addr = selected_server_addr(&server_entry, server_addr.as_ref());
|
||||
match send_clipboard_to_remote(&server_addr) {
|
||||
Ok(()) => status_label.set_text("Clipboard delivered to remote"),
|
||||
Err(err) => status_label.set_text(&format!("Clipboard send failed: {err}")),
|
||||
}
|
||||
status_label.set_text("Sending clipboard to remote...");
|
||||
let (result_tx, result_rx) = std::sync::mpsc::channel::<String>();
|
||||
std::thread::spawn(move || {
|
||||
let message = match send_clipboard_to_remote(&server_addr) {
|
||||
Ok(mode) => mode,
|
||||
Err(err) => format!("Clipboard send failed: {err}"),
|
||||
};
|
||||
let _ = result_tx.send(message);
|
||||
});
|
||||
|
||||
let status_label = status_label.clone();
|
||||
glib::timeout_add_local(Duration::from_millis(100), move || {
|
||||
match result_rx.try_recv() {
|
||||
Ok(message) => {
|
||||
status_label.set_text(&message);
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
||||
glib::ControlFlow::Continue
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
status_label.set_text("Clipboard send failed: launcher worker exited");
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -449,49 +622,58 @@ fn relaunch_with_settings(
|
||||
server_entry: >k::Entry,
|
||||
server_fallback: &str,
|
||||
toggle_key_combo: >k::ComboBoxText,
|
||||
preview: Option<&LauncherPreview>,
|
||||
) -> Result<()> {
|
||||
let server_addr = selected_server_addr(server_entry, server_fallback);
|
||||
let input_toggle_key = selected_toggle_key(toggle_key_combo);
|
||||
let mut state = state.borrow_mut();
|
||||
if matches!(state.view_mode, ViewMode::Breakout) {
|
||||
if let Some(preview) = preview {
|
||||
preview.set_enabled(false);
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
} else if let Some(preview) = preview {
|
||||
preview.set_enabled(true);
|
||||
}
|
||||
launch_or_restart_client(child_proc, &server_addr, &mut state, &input_toggle_key)
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
/// Reads local clipboard text and sends it to the remote server's paste RPC.
|
||||
fn send_clipboard_to_remote(server_addr: &str) -> Result<()> {
|
||||
let text = read_clipboard_text().ok_or_else(|| anyhow!("clipboard is empty or unavailable"))?;
|
||||
let req = paste::build_paste_request(&text)?;
|
||||
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
|
||||
rt.block_on(async {
|
||||
let channel = Channel::from_shared(server_addr.to_string())?
|
||||
.connect()
|
||||
.await?;
|
||||
let mut cli = RelayClient::new(channel);
|
||||
let reply = cli.paste_text(Request::new(req)).await?;
|
||||
if reply.get_ref().ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("server rejected paste: {}", reply.get_ref().error))
|
||||
}
|
||||
})
|
||||
#[cfg(not(coverage))]
|
||||
fn focus_signal_marker(path: &Path) -> u128 {
|
||||
std::fs::metadata(path)
|
||||
.ok()
|
||||
.and_then(|meta| meta.modified().ok())
|
||||
.and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn read_clipboard_text() -> Option<String> {
|
||||
if let Ok(out) = Command::new("sh")
|
||||
.arg("-lc")
|
||||
.arg(std::env::var("LESAVKA_CLIPBOARD_CMD").unwrap_or_else(
|
||||
|_| "wl-paste --no-newline --type text/plain || xclip -selection clipboard -o || xsel -b -o".to_string(),
|
||||
))
|
||||
.output()
|
||||
&& out.status.success()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
if !text.is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
fn sync_preview_runtime_state(
|
||||
window: >k::ApplicationWindow,
|
||||
preview_frame: >k::Frame,
|
||||
preview: Option<&LauncherPreview>,
|
||||
state: &LauncherState,
|
||||
child_running: bool,
|
||||
) {
|
||||
let breakout_active = child_running && matches!(state.view_mode, ViewMode::Breakout);
|
||||
preview_frame.set_visible(!breakout_active);
|
||||
if let Some(preview) = preview {
|
||||
preview.set_enabled(!breakout_active);
|
||||
}
|
||||
None
|
||||
if !breakout_active {
|
||||
window.present();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn queue_breakout_window_surface(window: >k::ApplicationWindow) {
|
||||
let window = window.clone();
|
||||
glib::timeout_add_local(Duration::from_millis(350), move || {
|
||||
window.minimize();
|
||||
glib::ControlFlow::Break
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
@ -515,6 +697,7 @@ fn spawn_client_process(
|
||||
command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key);
|
||||
command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher");
|
||||
command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1");
|
||||
command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path());
|
||||
for (key, value) in runtime_env_vars(state) {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
@ -18,6 +18,72 @@ pub struct UnifiedMonitorWindow {
|
||||
left_src: gst_app::AppSrc,
|
||||
right_src: gst_app::AppSrc,
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn spawn_wmctrl_placement(id: u32, rect: layout::Rect) {
|
||||
let x = rect.x;
|
||||
let y = rect.y;
|
||||
let w = rect.w;
|
||||
let h = rect.h;
|
||||
std::thread::spawn(move || {
|
||||
for attempt in 1..=12 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
let Some(window_id) = nth_lesavka_window_id(id as usize) else {
|
||||
tracing::debug!("⌛ wmctrl: eye-{id} not mapped yet (attempt {attempt})");
|
||||
continue;
|
||||
};
|
||||
let _ = Command::new("wmctrl")
|
||||
.args([
|
||||
"-i",
|
||||
"-r",
|
||||
&window_id,
|
||||
"-b",
|
||||
"remove,maximized_vert,maximized_horz",
|
||||
])
|
||||
.status();
|
||||
let status = Command::new("wmctrl")
|
||||
.args(["-i", "-r", &window_id, "-e", &format!("0,{x},{y},{w},{h}")])
|
||||
.status();
|
||||
match status {
|
||||
Ok(st) if st.success() => {
|
||||
tracing::info!("✅ wmctrl placed eye-{id} via {window_id} (attempt {attempt})");
|
||||
break;
|
||||
}
|
||||
_ => tracing::debug!("⌛ wmctrl: eye-{id} not ready for placement (attempt {attempt})"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn nth_lesavka_window_id(index: usize) -> Option<String> {
|
||||
let out = Command::new("wmctrl").args(["-lp"]).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut windows = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split_whitespace();
|
||||
let id = parts.next()?.to_string();
|
||||
let _desktop = parts.next()?;
|
||||
let _pid = parts.next()?;
|
||||
let _host = parts.next()?;
|
||||
let title = parts.collect::<Vec<_>>().join(" ");
|
||||
if title == "lesavka-client" || title.starts_with("Lesavka-eye-") {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
windows.sort();
|
||||
windows.dedup();
|
||||
if windows.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
windows.get(index).cloned()
|
||||
}
|
||||
#[allow(clippy::all)]
|
||||
impl MonitorWindow {
|
||||
#[cfg(coverage)]
|
||||
@ -101,123 +167,103 @@ impl MonitorWindow {
|
||||
if sink_elem.find_property("window-title").is_some() {
|
||||
let _ = sink_elem.set_property("window-title", &format!("Lesavka-eye-{id}"));
|
||||
}
|
||||
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) {
|
||||
if let Ok(overlay) = sink_elem.dynamic_cast::<VideoOverlay>() {
|
||||
// 1. Tell glimagesink how to crop the texture in its own window
|
||||
let _ = overlay.set_render_rectangle(0, 0, r.w, r.h);
|
||||
debug!(
|
||||
"🔲 eye-{id} → render_rectangle({}, {}, {}, {})",
|
||||
0, 0, r.w, r.h
|
||||
);
|
||||
}
|
||||
|
||||
// 2. **Compositor-level** placement (Wayland only)
|
||||
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||
use std::process::{Command, ExitStatus};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
// 2. **Compositor-level** placement (Wayland only)
|
||||
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||
use std::process::{Command, ExitStatus};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
// A small helper struct so the two branches return the same type
|
||||
struct Placer {
|
||||
name: &'static str,
|
||||
run: Arc<dyn Fn(&str) -> std::io::Result<ExitStatus> + Send + Sync>,
|
||||
}
|
||||
// A small helper struct so the two branches return the same type
|
||||
struct Placer {
|
||||
name: &'static str,
|
||||
run: Arc<dyn Fn(&str) -> std::io::Result<ExitStatus> + Send + Sync>,
|
||||
}
|
||||
|
||||
let placer = if Command::new("swaymsg")
|
||||
.arg("-t")
|
||||
.arg("get_tree")
|
||||
.output()
|
||||
.is_ok()
|
||||
{
|
||||
Placer {
|
||||
name: "swaymsg",
|
||||
run: Arc::new(|cmd| Command::new("swaymsg").arg(cmd).status()),
|
||||
}
|
||||
} else if Command::new("hyprctl").arg("version").output().is_ok() {
|
||||
Placer {
|
||||
name: "hyprctl",
|
||||
run: Arc::new(|cmd| {
|
||||
Command::new("hyprctl")
|
||||
.args(["dispatch", "exec", cmd])
|
||||
.status()
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
Placer {
|
||||
name: "noop",
|
||||
run: Arc::new(|_| {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"no swaymsg/hyprctl found",
|
||||
))
|
||||
}),
|
||||
}
|
||||
let placer = if Command::new("swaymsg")
|
||||
.arg("-t")
|
||||
.arg("get_tree")
|
||||
.output()
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Some(Placer {
|
||||
name: "swaymsg",
|
||||
run: Arc::new(|cmd| Command::new("swaymsg").arg(cmd).status()),
|
||||
})
|
||||
} else if Command::new("hyprctl")
|
||||
.arg("version")
|
||||
.output()
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Some(Placer {
|
||||
name: "hyprctl",
|
||||
run: Arc::new(|cmd| {
|
||||
Command::new("hyprctl")
|
||||
.args(["dispatch", "exec", cmd])
|
||||
.status()
|
||||
}),
|
||||
})
|
||||
} else if std::env::var_os("DISPLAY").is_some()
|
||||
&& Command::new("wmctrl").arg("-m").output().is_ok()
|
||||
{
|
||||
spawn_wmctrl_placement(id, *r);
|
||||
None
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(placer) = placer {
|
||||
let cmd = match placer.name {
|
||||
// Criteria string that works for i3-/sway-compatible IPC
|
||||
"swaymsg" | "hyprctl" => format!(
|
||||
r#"[title="^Lesavka-eye-{id}$"] \
|
||||
resize set {w} {h}; \
|
||||
move absolute position {x} {y}"#,
|
||||
w = r.w,
|
||||
h = r.h,
|
||||
x = r.x,
|
||||
y = r.y,
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
if placer.name != "noop" {
|
||||
let cmd = match placer.name {
|
||||
// Criteria string that works for i3-/sway-compatible IPC
|
||||
"swaymsg" | "hyprctl" => format!(
|
||||
r#"[title="^Lesavka-eye-{id}$"] \
|
||||
resize set {w} {h}; \
|
||||
move absolute position {x} {y}"#,
|
||||
w = r.w,
|
||||
h = r.h,
|
||||
x = r.x,
|
||||
y = r.y,
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
// Retry in a detached thread - avoids blocking GStreamer
|
||||
let placename = placer.name;
|
||||
let runner = placer.run.clone();
|
||||
thread::spawn(move || {
|
||||
for attempt in 1..=10 {
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
match runner(&cmd) {
|
||||
Ok(st) if st.success() => {
|
||||
tracing::info!(
|
||||
"✅ {placename}: placed eye-{id} (attempt {attempt})"
|
||||
);
|
||||
break;
|
||||
}
|
||||
_ => tracing::debug!(
|
||||
"⌛ {placename}: eye-{id} not mapped yet (attempt {attempt})"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 3. X11 / Xwayland placement via wmctrl
|
||||
else if std::env::var_os("DISPLAY").is_some() {
|
||||
let title = format!("Lesavka-eye-{id}");
|
||||
let w = r.w;
|
||||
let h = r.h;
|
||||
let x = r.x;
|
||||
let y = r.y;
|
||||
std::thread::spawn(move || {
|
||||
// Retry in a detached thread - avoids blocking GStreamer
|
||||
let placename = placer.name;
|
||||
let runner = placer.run.clone();
|
||||
thread::spawn(move || {
|
||||
for attempt in 1..=10 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
let status = Command::new("wmctrl")
|
||||
.args(["-r", &title, "-e", &format!("0,{x},{y},{w},{h}")])
|
||||
.status();
|
||||
match status {
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
match runner(&cmd) {
|
||||
Ok(st) if st.success() => {
|
||||
tracing::info!(
|
||||
"✅ wmctrl placed eye-{id} (attempt {attempt})"
|
||||
"✅ {placename}: placed eye-{id} (attempt {attempt})"
|
||||
);
|
||||
break;
|
||||
}
|
||||
_ => tracing::debug!(
|
||||
"⌛ wmctrl: eye-{id} not mapped yet (attempt {attempt})"
|
||||
"⌛ {placename}: eye-{id} not mapped yet (attempt {attempt})"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 3. X11 / Xwayland placement via wmctrl
|
||||
else if std::env::var_os("DISPLAY").is_some() {
|
||||
spawn_wmctrl_placement(id, *r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
//! Shared HID mapping helpers used by both the client and server crates.
|
||||
|
||||
pub type KeyboardHidReport = [u8; 8];
|
||||
|
||||
/// Map a printable character to a USB HID usage plus modifier byte.
|
||||
///
|
||||
/// Inputs: a Unicode scalar value that should be typed through the HID gadget.
|
||||
@ -54,9 +56,34 @@ pub fn char_to_usage(c: char) -> Option<(u8, u8)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append the HID report sequence needed to type a character.
|
||||
///
|
||||
/// Inputs: an output buffer plus the character that should be typed.
|
||||
/// Outputs: `true` when the character is supported and reports were appended.
|
||||
/// Why: some targets miss uppercase or shifted characters when they arrive as a
|
||||
/// single modifier+usage pulse, so we emit a more human-like modifier press,
|
||||
/// key press, key release, and modifier release sequence.
|
||||
pub fn append_char_reports(out: &mut Vec<KeyboardHidReport>, c: char) -> bool {
|
||||
let Some((usage, mods)) = char_to_usage(c) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if mods != 0 {
|
||||
out.push([mods, 0, 0, 0, 0, 0, 0, 0]);
|
||||
out.push([mods, 0, usage, 0, 0, 0, 0, 0]);
|
||||
out.push([mods, 0, 0, 0, 0, 0, 0, 0]);
|
||||
out.push([0; 8]);
|
||||
} else {
|
||||
out.push([0, 0, usage, 0, 0, 0, 0, 0]);
|
||||
out.push([0; 8]);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::char_to_usage;
|
||||
use super::{append_char_reports, char_to_usage};
|
||||
|
||||
#[test]
|
||||
fn char_to_usage_maps_letters_numbers_and_shifted_symbols() {
|
||||
@ -77,4 +104,31 @@ mod tests {
|
||||
assert_eq!(char_to_usage('é'), None);
|
||||
assert_eq!(char_to_usage('\u{2603}'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_char_reports_expands_shifted_chars_into_four_steps() {
|
||||
let mut reports = Vec::new();
|
||||
assert!(append_char_reports(&mut reports, 'A'));
|
||||
assert_eq!(reports.len(), 4);
|
||||
assert_eq!(reports[0], [0x02, 0, 0, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(reports[1], [0x02, 0, 0x04, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(reports[2], [0x02, 0, 0, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(reports[3], [0; 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_char_reports_keeps_unshifted_chars_as_press_and_release() {
|
||||
let mut reports = Vec::new();
|
||||
assert!(append_char_reports(&mut reports, 'a'));
|
||||
assert_eq!(reports.len(), 2);
|
||||
assert_eq!(reports[0], [0, 0, 0x04, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(reports[1], [0; 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_char_reports_rejects_unsupported_chars() {
|
||||
let mut reports = Vec::new();
|
||||
assert!(!append_char_reports(&mut reports, '🙂'));
|
||||
assert!(reports.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,12 +23,12 @@
|
||||
"client/src/input/inputs.rs": {
|
||||
"clippy_warnings": 42,
|
||||
"doc_debt": 11,
|
||||
"loc": 550
|
||||
"loc": 562
|
||||
},
|
||||
"client/src/input/keyboard.rs": {
|
||||
"clippy_warnings": 24,
|
||||
"doc_debt": 17,
|
||||
"loc": 565
|
||||
"loc": 570
|
||||
},
|
||||
"client/src/input/keymap.rs": {
|
||||
"clippy_warnings": 8,
|
||||
@ -50,6 +50,11 @@
|
||||
"doc_debt": 8,
|
||||
"loc": 317
|
||||
},
|
||||
"client/src/launcher/clipboard.rs": {
|
||||
"clippy_warnings": 2,
|
||||
"doc_debt": 1,
|
||||
"loc": 176
|
||||
},
|
||||
"client/src/launcher/devices.rs": {
|
||||
"clippy_warnings": 6,
|
||||
"doc_debt": 3,
|
||||
@ -61,14 +66,14 @@
|
||||
"loc": 172
|
||||
},
|
||||
"client/src/launcher/mod.rs": {
|
||||
"clippy_warnings": 4,
|
||||
"clippy_warnings": 6,
|
||||
"doc_debt": 4,
|
||||
"loc": 176
|
||||
"loc": 182
|
||||
},
|
||||
"client/src/launcher/preview.rs": {
|
||||
"clippy_warnings": 20,
|
||||
"doc_debt": 7,
|
||||
"loc": 258
|
||||
"clippy_warnings": 22,
|
||||
"doc_debt": 9,
|
||||
"loc": 291
|
||||
},
|
||||
"client/src/launcher/state.rs": {
|
||||
"clippy_warnings": 8,
|
||||
@ -77,8 +82,8 @@
|
||||
},
|
||||
"client/src/launcher/ui.rs": {
|
||||
"clippy_warnings": 8,
|
||||
"doc_debt": 7,
|
||||
"loc": 615
|
||||
"doc_debt": 8,
|
||||
"loc": 838
|
||||
},
|
||||
"client/src/layout.rs": {
|
||||
"clippy_warnings": 6,
|
||||
@ -117,8 +122,8 @@
|
||||
},
|
||||
"client/src/output/video.rs": {
|
||||
"clippy_warnings": 36,
|
||||
"doc_debt": 2,
|
||||
"loc": 499
|
||||
"doc_debt": 4,
|
||||
"loc": 545
|
||||
},
|
||||
"client/src/paste.rs": {
|
||||
"clippy_warnings": 2,
|
||||
@ -138,7 +143,7 @@
|
||||
"common/src/hid.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 2,
|
||||
"loc": 80
|
||||
"loc": 134
|
||||
},
|
||||
"common/src/lib.rs": {
|
||||
"clippy_warnings": 0,
|
||||
@ -198,7 +203,7 @@
|
||||
"server/src/paste.rs": {
|
||||
"clippy_warnings": 6,
|
||||
"doc_debt": 3,
|
||||
"loc": 204
|
||||
"loc": 205
|
||||
},
|
||||
"server/src/runtime_support.rs": {
|
||||
"clippy_warnings": 14,
|
||||
|
||||
@ -17,12 +17,12 @@
|
||||
"loc": 368
|
||||
},
|
||||
"client/src/input/inputs.rs": {
|
||||
"line_percent": 97.0059880239521,
|
||||
"loc": 550
|
||||
"line_percent": 97.32,
|
||||
"loc": 562
|
||||
},
|
||||
"client/src/input/keyboard.rs": {
|
||||
"line_percent": 95.27559055118111,
|
||||
"loc": 565
|
||||
"line_percent": 95.7,
|
||||
"loc": 570
|
||||
},
|
||||
"client/src/input/keymap.rs": {
|
||||
"line_percent": 100.0,
|
||||
@ -36,6 +36,10 @@
|
||||
"line_percent": 97.32142857142857,
|
||||
"loc": 317
|
||||
},
|
||||
"client/src/launcher/clipboard.rs": {
|
||||
"line_percent": 97.96,
|
||||
"loc": 176
|
||||
},
|
||||
"client/src/launcher/devices.rs": {
|
||||
"line_percent": 98.09523809523807,
|
||||
"loc": 154
|
||||
@ -45,8 +49,8 @@
|
||||
"loc": 172
|
||||
},
|
||||
"client/src/launcher/mod.rs": {
|
||||
"line_percent": 96.15384615384616,
|
||||
"loc": 176
|
||||
"line_percent": 95.08,
|
||||
"loc": 181
|
||||
},
|
||||
"client/src/launcher/state.rs": {
|
||||
"line_percent": 97.97297297297297,
|
||||
@ -54,7 +58,7 @@
|
||||
},
|
||||
"client/src/launcher/ui.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 615
|
||||
"loc": 838
|
||||
},
|
||||
"client/src/layout.rs": {
|
||||
"line_percent": 97.72727272727273,
|
||||
@ -77,8 +81,8 @@
|
||||
"loc": 155
|
||||
},
|
||||
"client/src/output/video.rs": {
|
||||
"line_percent": 96.11650485436894,
|
||||
"loc": 499
|
||||
"line_percent": 96.23,
|
||||
"loc": 545
|
||||
},
|
||||
"client/src/paste.rs": {
|
||||
"line_percent": 96.29629629629629,
|
||||
@ -94,7 +98,7 @@
|
||||
},
|
||||
"common/src/hid.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 80
|
||||
"loc": 134
|
||||
},
|
||||
"common/src/lib.rs": {
|
||||
"line_percent": 100.0,
|
||||
@ -133,8 +137,8 @@
|
||||
"loc": 508
|
||||
},
|
||||
"server/src/paste.rs": {
|
||||
"line_percent": 96.73913043478261,
|
||||
"loc": 204
|
||||
"line_percent": 97.08,
|
||||
"loc": 205
|
||||
},
|
||||
"server/src/runtime_support.rs": {
|
||||
"line_percent": 96.42857142857143,
|
||||
|
||||
@ -8,7 +8,7 @@ use tokio::fs::File;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use lesavka_common::hid::char_to_usage;
|
||||
use lesavka_common::hid::{append_char_reports, char_to_usage};
|
||||
use lesavka_common::lesavka::PasteRequest;
|
||||
use lesavka_common::paste::decode_shared_key;
|
||||
|
||||
@ -60,12 +60,13 @@ pub async fn type_text(kb: &Mutex<File>, text: &str) -> Result<()> {
|
||||
|
||||
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;
|
||||
let mut reports = Vec::with_capacity(4);
|
||||
if append_char_reports(&mut reports, c) {
|
||||
for report in reports {
|
||||
kb.write_all(&report).await?;
|
||||
if delay_ms > 0 {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -166,7 +167,7 @@ mod tests {
|
||||
let mut bytes = Vec::new();
|
||||
let mut file = File::open(&path).await.expect("reopen temp file");
|
||||
file.read_to_end(&mut bytes).await.expect("read reports");
|
||||
assert_eq!(bytes.len(), 48);
|
||||
assert_eq!(bytes.len(), 96);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -286,14 +286,41 @@ mod keyboard_contract {
|
||||
return;
|
||||
};
|
||||
let (mut agg, mut rx) = new_aggregator(dev);
|
||||
|
||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_A);
|
||||
agg.reset_state();
|
||||
assert!(agg.pressed_keys.is_empty());
|
||||
let pkt = rx.try_recv().expect("empty report after reset");
|
||||
assert_eq!(pkt.data, vec![0; 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn reset_state_when_idle_still_emits_an_empty_report() {
|
||||
let Some(dev) = open_any_keyboard_device()
|
||||
.or_else(|| build_keyboard("lesavka-include-kbd-reset-idle").map(|(_, dev)| dev))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (mut agg, mut rx) = new_aggregator(dev);
|
||||
agg.reset_state();
|
||||
let pkt = rx.try_recv().expect("idle reset should still publish empty report");
|
||||
assert_eq!(pkt.data, vec![0; 8]);
|
||||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
fn pressed_keys_snapshot_returns_the_current_keyset() {
|
||||
let Some(dev) = open_any_keyboard_device()
|
||||
.or_else(|| build_keyboard("lesavka-include-kbd-snapshot").map(|(_, dev)| dev))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (mut agg, _) = new_aggregator(dev);
|
||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_A);
|
||||
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
||||
let snapshot = agg.pressed_keys_snapshot();
|
||||
assert!(snapshot.contains(&evdev::KeyCode::KEY_A));
|
||||
assert!(snapshot.contains(&evdev::KeyCode::KEY_LEFTCTRL));
|
||||
assert_eq!(snapshot.len(), 2);
|
||||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
fn set_send_false_blocks_manual_empty_report() {
|
||||
@ -307,7 +334,6 @@ mod keyboard_contract {
|
||||
agg.send_empty_report();
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn process_events_respects_send_toggle() {
|
||||
@ -326,7 +352,6 @@ mod keyboard_contract {
|
||||
agg.process_events();
|
||||
assert!(rx.try_recv().is_err(), "send-disabled aggregator should not publish reports");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn paste_chord_active_supports_ctrl_v_variant() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user