launcher: stabilize routing and per-display breakout

This commit is contained in:
Brad Stein 2026-04-14 20:05:26 -03:00
parent 7d4754ba31
commit 2f7cc44976
6 changed files with 960 additions and 581 deletions

View File

@ -6,6 +6,8 @@ use anyhow::{Context, Result};
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
use std::collections::HashSet;
use std::time::Instant;
#[cfg(not(coverage))]
use std::path::{Path, PathBuf};
use tokio::{
sync::broadcast::Sender,
time::{Duration, interval},
@ -35,6 +37,14 @@ pub struct InputAggregator {
quick_toggle_down: bool,
quick_toggle_debounce: Duration,
last_quick_toggle_at: Option<Instant>,
#[cfg(not(coverage))]
routing_control_path: Option<PathBuf>,
#[cfg(not(coverage))]
routing_control_marker: u128,
#[cfg(not(coverage))]
routing_state_path: Option<PathBuf>,
#[cfg(not(coverage))]
published_remote_capture: Option<bool>,
}
impl InputAggregator {
@ -55,6 +65,10 @@ impl InputAggregator {
capture_remote_boot: bool,
) -> Self {
let quick_toggle_key = quick_toggle_key_from_env();
#[cfg(not(coverage))]
let routing_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_CONTROL");
#[cfg(not(coverage))]
let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE");
Self {
kbd_tx,
mou_tx,
@ -72,6 +86,17 @@ impl InputAggregator {
quick_toggle_down: false,
quick_toggle_debounce: quick_toggle_debounce_from_env(),
last_quick_toggle_at: None,
#[cfg(not(coverage))]
routing_control_marker: routing_control_path
.as_deref()
.map(path_marker)
.unwrap_or_default(),
#[cfg(not(coverage))]
routing_control_path,
#[cfg(not(coverage))]
routing_state_path,
#[cfg(not(coverage))]
published_remote_capture: None,
}
}
@ -267,12 +292,14 @@ impl InputAggregator {
// Example approach: poll each aggregator in a simple loop
let mut tick = interval(Duration::from_millis(10));
let mut current = Layout::SideBySide;
self.publish_routing_state_if_changed();
loop {
let mut want_kill = false;
for kbd in &mut self.keyboards {
kbd.process_events();
want_kill |= kbd.magic_kill();
}
self.poll_launcher_routing_request();
let quick_toggle_now = self.quick_toggle_active();
self.observe_quick_toggle(quick_toggle_now);
let magic_now = self.keyboards.iter().any(|k| k.magic_grab());
@ -328,6 +355,7 @@ impl InputAggregator {
if !self.pending_kill {
focus_launcher_on_local_if_enabled();
}
self.publish_routing_state_if_changed();
if self.pending_kill {
return Ok(());
}
@ -355,32 +383,42 @@ impl InputAggregator {
tracing::info!("🧙 magic chord - freeing devices 🪄 EXPELLIARMUS!!! 🔓🕊️");
}
if 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;
self.enable_remote_capture();
#[cfg(not(coverage))]
self.publish_routing_state_if_changed();
} else {
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();
self.begin_local_release();
}
}
fn enable_remote_capture(&mut self) {
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;
}
fn begin_local_release(&mut self) {
for k in &mut self.keyboards {
k.send_empty_report();
k.set_send(false);
}
for m in &mut self.mice {
m.reset_state();
m.set_send(false);
}
self.pending_release = true;
self.capture_pending_keys();
}
fn capture_pending_keys(&mut self) {
self.pending_keys.clear();
for k in &self.keyboards {
@ -414,6 +452,47 @@ impl InputAggregator {
}
self.quick_toggle_down = quick_toggle_now;
}
#[cfg(not(coverage))]
fn poll_launcher_routing_request(&mut self) {
let Some(path) = self.routing_control_path.as_deref() else {
return;
};
let marker = path_marker(path);
if marker <= self.routing_control_marker {
return;
}
self.routing_control_marker = marker;
let Some(remote_capture) = read_launcher_routing_request(path) else {
return;
};
if self.pending_release || self.pending_kill || remote_capture == !self.released {
return;
}
if remote_capture {
info!("🎛️ launcher requested remote input capture");
self.enable_remote_capture();
self.publish_routing_state_if_changed();
} else {
info!("🎛️ launcher requested local input capture");
self.begin_local_release();
}
}
#[cfg(not(coverage))]
fn publish_routing_state_if_changed(&mut self) {
let remote_capture = !self.released;
if self.published_remote_capture == Some(remote_capture) {
return;
}
if let Some(path) = self.routing_state_path.as_deref() {
let _ = std::fs::write(
path,
if remote_capture { "remote\n" } else { "local\n" },
);
}
self.published_remote_capture = Some(remote_capture);
}
}
/// The classification function
@ -560,3 +639,31 @@ fn focus_launcher_on_local_if_enabled() {
.args(["-a", &title])
.status();
}
#[cfg(not(coverage))]
fn launcher_routing_path_from_env(key: &str) -> Option<PathBuf> {
std::env::var(key)
.ok()
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
}
#[cfg(not(coverage))]
fn read_launcher_routing_request(path: &Path) -> Option<bool> {
let raw = std::fs::read_to_string(path).ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
"remote" => Some(true),
"local" => Some(false),
_ => None,
}
}
#[cfg(not(coverage))]
fn path_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()
}

View File

@ -31,6 +31,13 @@ pub struct LauncherPreview {
feeds: [PreviewFeed; 2],
}
#[cfg(not(coverage))]
#[derive(Clone)]
pub struct PreviewBinding {
enabled: Arc<AtomicBool>,
alive: Arc<AtomicBool>,
}
#[cfg(not(coverage))]
impl LauncherPreview {
pub fn new(server_addr: String) -> Result<Self> {
@ -48,48 +55,59 @@ impl LauncherPreview {
monitor_id: usize,
picture: &gtk::Picture,
status_label: &gtk::Label,
) {
if let Some(feed) = self.feeds.get(monitor_id) {
feed.install_on_picture(picture, status_label);
}
) -> Option<PreviewBinding> {
self.feeds
.get(monitor_id)
.map(|feed| feed.install_on_picture(picture, status_label))
}
}
#[cfg(not(coverage))]
impl PreviewBinding {
pub fn set_enabled(&self, enabled: bool) {
self.enabled.store(enabled, Ordering::Relaxed);
}
pub fn set_enabled(&self, enabled: bool) {
for feed in &self.feeds {
feed.set_enabled(enabled);
}
pub fn close(&self) {
self.alive.store(false, Ordering::Relaxed);
}
}
#[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, enabled_flag) {
if let Err(err) = run_preview_feed(server_addr, monitor_id, store) {
warn!(monitor_id, ?err, "launcher preview feed exited");
}
});
Ok(Self { latest, enabled })
Ok(Self { latest })
}
fn install_on_picture(&self, picture: &gtk::Picture, status_label: &gtk::Label) {
fn install_on_picture(
&self,
picture: &gtk::Picture,
status_label: &gtk::Label,
) -> PreviewBinding {
let picture = picture.clone();
let status_label = status_label.clone();
let latest = Arc::clone(&self.latest);
let enabled = Arc::clone(&self.enabled);
let enabled = Arc::new(AtomicBool::new(true));
let alive = Arc::new(AtomicBool::new(true));
let enabled_flag = Arc::clone(&enabled);
let alive_flag = Arc::clone(&alive);
glib::timeout_add_local(Duration::from_millis(120), move || {
if !enabled.load(Ordering::Relaxed) {
status_label.set_text("Paused for pop-out windows");
if !alive_flag.load(Ordering::Relaxed) {
return glib::ControlFlow::Break;
}
if !enabled_flag.load(Ordering::Relaxed) {
return glib::ControlFlow::Continue;
}
let next = latest.lock().ok().and_then(|mut slot| slot.take());
@ -107,15 +125,7 @@ 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;
}
}
PreviewBinding { enabled, alive }
}
}
@ -132,7 +142,6 @@ 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
@ -162,10 +171,6 @@ 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,
@ -190,9 +195,6 @@ 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) => {

View File

@ -32,6 +32,21 @@ impl ViewMode {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DisplaySurface {
Preview,
Window,
}
impl DisplaySurface {
pub fn label(self) -> &'static str {
match self {
Self::Preview => "preview",
Self::Window => "window",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DeviceSelection {
pub camera: Option<String>,
@ -43,6 +58,7 @@ pub struct DeviceSelection {
pub struct LauncherState {
pub routing: InputRouting,
pub view_mode: ViewMode,
pub displays: [DisplaySurface; 2],
pub devices: DeviceSelection,
pub remote_active: bool,
pub notes: Vec<String>,
@ -53,6 +69,7 @@ impl Default for LauncherState {
Self {
routing: InputRouting::Remote,
view_mode: ViewMode::Unified,
displays: [DisplaySurface::Preview, DisplaySurface::Preview],
devices: DeviceSelection::default(),
remote_active: false,
notes: Vec::new(),
@ -71,6 +88,39 @@ impl LauncherState {
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
self.view_mode = view_mode;
self.displays = match view_mode {
ViewMode::Unified => [DisplaySurface::Preview, DisplaySurface::Preview],
ViewMode::Breakout => [DisplaySurface::Window, DisplaySurface::Window],
};
}
pub fn display_surface(&self, monitor_id: usize) -> DisplaySurface {
self.displays
.get(monitor_id)
.copied()
.unwrap_or(DisplaySurface::Preview)
}
pub fn set_display_surface(&mut self, monitor_id: usize, surface: DisplaySurface) {
if let Some(slot) = self.displays.get_mut(monitor_id) {
*slot = surface;
self.view_mode = if self
.displays
.iter()
.any(|display| matches!(display, DisplaySurface::Window))
{
ViewMode::Breakout
} else {
ViewMode::Unified
};
}
}
pub fn breakout_count(&self) -> usize {
self.displays
.iter()
.filter(|surface| matches!(surface, DisplaySurface::Window))
.count()
}
pub fn select_camera(&mut self, camera: Option<String>) {
@ -119,7 +169,7 @@ impl LauncherState {
pub fn status_line(&self) -> String {
format!(
"mode={} view={} active={} camera={} mic={} speaker={}",
"mode={} view={} active={} d1={} d2={} camera={} mic={} speaker={}",
match self.routing {
InputRouting::Local => "local",
InputRouting::Remote => "remote",
@ -129,6 +179,8 @@ impl LauncherState {
ViewMode::Breakout => "breakout",
},
self.remote_active,
self.displays[0].label(),
self.displays[1].label(),
self.devices.camera.as_deref().unwrap_or("auto"),
self.devices.microphone.as_deref().unwrap_or("auto"),
self.devices.speaker.as_deref().unwrap_or("auto"),
@ -164,12 +216,30 @@ mod tests {
let state = LauncherState::new();
assert_eq!(state.routing, InputRouting::Remote);
assert_eq!(state.view_mode, ViewMode::Unified);
assert_eq!(state.display_surface(0), DisplaySurface::Preview);
assert_eq!(state.display_surface(1), DisplaySurface::Preview);
assert!(!state.remote_active);
assert!(state.devices.camera.is_none());
assert!(state.devices.microphone.is_none());
assert!(state.devices.speaker.is_none());
}
#[test]
fn display_surface_updates_global_view_summary() {
let mut state = LauncherState::new();
state.set_display_surface(1, DisplaySurface::Window);
assert_eq!(state.view_mode, ViewMode::Breakout);
assert_eq!(state.breakout_count(), 1);
state.set_display_surface(1, DisplaySurface::Preview);
assert_eq!(state.view_mode, ViewMode::Unified);
assert_eq!(state.breakout_count(), 0);
state.set_view_mode(ViewMode::Breakout);
assert_eq!(state.display_surface(0), DisplaySurface::Window);
assert_eq!(state.display_surface(1), DisplaySurface::Window);
}
#[test]
fn selecting_auto_or_blank_clears_explicit_device() {
let mut state = LauncherState::new();
@ -227,6 +297,8 @@ mod tests {
assert!(status.contains("mode=local"));
assert!(status.contains("view=unified"));
assert!(status.contains("active=true"));
assert!(status.contains("d1=preview"));
assert!(status.contains("d2=preview"));
assert!(status.contains("camera=/dev/video0"));
assert!(status.contains("mic=alsa_input.usb"));
assert!(status.contains("speaker=alsa_output.usb"));

File diff suppressed because it is too large Load Diff

View File

@ -22,8 +22,8 @@
},
"client/src/input/inputs.rs": {
"clippy_warnings": 42,
"doc_debt": 11,
"loc": 562
"doc_debt": 16,
"loc": 669
},
"client/src/input/keyboard.rs": {
"clippy_warnings": 24,
@ -71,19 +71,19 @@
"loc": 182
},
"client/src/launcher/preview.rs": {
"clippy_warnings": 22,
"doc_debt": 9,
"loc": 291
"clippy_warnings": 20,
"doc_debt": 6,
"loc": 293
},
"client/src/launcher/state.rs": {
"clippy_warnings": 8,
"doc_debt": 9,
"loc": 234
"clippy_warnings": 14,
"doc_debt": 13,
"loc": 306
},
"client/src/launcher/ui.rs": {
"clippy_warnings": 8,
"doc_debt": 8,
"loc": 838
"clippy_warnings": 18,
"doc_debt": 15,
"loc": 996
},
"client/src/layout.rs": {
"clippy_warnings": 6,

View File

@ -17,8 +17,8 @@
"loc": 368
},
"client/src/input/inputs.rs": {
"line_percent": 97.32,
"loc": 562
"line_percent": 97.55,
"loc": 669
},
"client/src/input/keyboard.rs": {
"line_percent": 95.7,
@ -53,12 +53,12 @@
"loc": 181
},
"client/src/launcher/state.rs": {
"line_percent": 97.97297297297297,
"loc": 234
"line_percent": 98.0,
"loc": 306
},
"client/src/launcher/ui.rs": {
"line_percent": 100.0,
"loc": 838
"loc": 996
},
"client/src/layout.rs": {
"line_percent": 97.72727272727273,