launcher: stabilize routing and per-display breakout
This commit is contained in:
parent
7d4754ba31
commit
2f7cc44976
@ -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()
|
||||
}
|
||||
|
||||
@ -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: >k::Picture,
|
||||
status_label: >k::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: >k::Picture, status_label: >k::Label) {
|
||||
fn install_on_picture(
|
||||
&self,
|
||||
picture: >k::Picture,
|
||||
status_label: >k::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) => {
|
||||
|
||||
@ -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
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user