2026-04-13 23:11:35 -03:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
use super::devices::DeviceCatalog;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub enum InputRouting {
|
|
|
|
|
Local,
|
|
|
|
|
Remote,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl InputRouting {
|
|
|
|
|
pub fn as_env(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Local => "0",
|
|
|
|
|
Self::Remote => "1",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub enum ViewMode {
|
|
|
|
|
Unified,
|
|
|
|
|
Breakout,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ViewMode {
|
|
|
|
|
pub fn as_env(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Unified => "unified",
|
|
|
|
|
Self::Breakout => "breakout",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 20:05:26 -03:00
|
|
|
#[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",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 23:03:18 -03:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct CapturePowerStatus {
|
|
|
|
|
pub available: bool,
|
|
|
|
|
pub enabled: bool,
|
|
|
|
|
pub unit: String,
|
|
|
|
|
pub detail: String,
|
|
|
|
|
pub active_leases: u32,
|
|
|
|
|
pub mode: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for CapturePowerStatus {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
available: false,
|
|
|
|
|
enabled: false,
|
|
|
|
|
unit: "relay.service".to_string(),
|
|
|
|
|
detail: "unknown".to_string(),
|
|
|
|
|
active_leases: 0,
|
|
|
|
|
mode: "auto".to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
|
|
|
pub struct DeviceSelection {
|
|
|
|
|
pub camera: Option<String>,
|
|
|
|
|
pub microphone: Option<String>,
|
|
|
|
|
pub speaker: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct LauncherState {
|
|
|
|
|
pub routing: InputRouting,
|
|
|
|
|
pub view_mode: ViewMode,
|
2026-04-14 20:05:26 -03:00
|
|
|
pub displays: [DisplaySurface; 2],
|
2026-04-13 23:11:35 -03:00
|
|
|
pub devices: DeviceSelection,
|
2026-04-15 04:44:06 -03:00
|
|
|
pub swap_key: String,
|
|
|
|
|
pub swap_key_binding: bool,
|
2026-04-14 23:03:18 -03:00
|
|
|
pub capture_power: CapturePowerStatus,
|
2026-04-13 23:11:35 -03:00
|
|
|
pub remote_active: bool,
|
|
|
|
|
pub notes: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for LauncherState {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
2026-04-14 18:44:40 -03:00
|
|
|
routing: InputRouting::Remote,
|
2026-04-14 14:38:03 -03:00
|
|
|
view_mode: ViewMode::Unified,
|
2026-04-14 20:05:26 -03:00
|
|
|
displays: [DisplaySurface::Preview, DisplaySurface::Preview],
|
2026-04-13 23:11:35 -03:00
|
|
|
devices: DeviceSelection::default(),
|
2026-04-15 04:44:06 -03:00
|
|
|
swap_key: "pause".to_string(),
|
|
|
|
|
swap_key_binding: false,
|
2026-04-14 23:03:18 -03:00
|
|
|
capture_power: CapturePowerStatus::default(),
|
2026-04-13 23:11:35 -03:00
|
|
|
remote_active: false,
|
|
|
|
|
notes: Vec::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl LauncherState {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self::default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn set_routing(&mut self, routing: InputRouting) {
|
|
|
|
|
self.routing = routing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
|
|
|
|
|
self.view_mode = view_mode;
|
2026-04-14 20:05:26 -03:00
|
|
|
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()
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn select_camera(&mut self, camera: Option<String>) {
|
|
|
|
|
self.devices.camera = normalize_selection(camera);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn select_microphone(&mut self, microphone: Option<String>) {
|
|
|
|
|
self.devices.microphone = normalize_selection(microphone);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn select_speaker(&mut self, speaker: Option<String>) {
|
|
|
|
|
self.devices.speaker = normalize_selection(speaker);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) {
|
|
|
|
|
if self.devices.camera.is_none() {
|
|
|
|
|
self.devices.camera = catalog.cameras.first().cloned();
|
|
|
|
|
}
|
|
|
|
|
if self.devices.microphone.is_none() {
|
|
|
|
|
self.devices.microphone = catalog.microphones.first().cloned();
|
|
|
|
|
}
|
|
|
|
|
if self.devices.speaker.is_none() {
|
|
|
|
|
self.devices.speaker = catalog.speakers.first().cloned();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 04:44:06 -03:00
|
|
|
pub fn set_swap_key(&mut self, swap_key: impl Into<String>) {
|
|
|
|
|
self.swap_key = normalize_swap_key(swap_key.into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn begin_swap_key_binding(&mut self) {
|
|
|
|
|
self.swap_key_binding = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn finish_swap_key_binding(&mut self) {
|
|
|
|
|
self.swap_key_binding = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
pub fn start_remote(&mut self) -> bool {
|
|
|
|
|
if self.remote_active {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
self.remote_active = true;
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn stop_remote(&mut self) -> bool {
|
|
|
|
|
if !self.remote_active {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
self.remote_active = false;
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn push_note(&mut self, note: impl Into<String>) {
|
|
|
|
|
self.notes.push(note.into());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 23:03:18 -03:00
|
|
|
pub fn set_capture_power(&mut self, power: CapturePowerStatus) {
|
|
|
|
|
self.capture_power = power;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
pub fn status_line(&self) -> String {
|
|
|
|
|
format!(
|
2026-04-15 04:44:06 -03:00
|
|
|
"mode={} view={} active={} power={} d1={} d2={} camera={} mic={} speaker={} swap={}",
|
2026-04-13 23:11:35 -03:00
|
|
|
match self.routing {
|
|
|
|
|
InputRouting::Local => "local",
|
|
|
|
|
InputRouting::Remote => "remote",
|
|
|
|
|
},
|
|
|
|
|
match self.view_mode {
|
|
|
|
|
ViewMode::Unified => "unified",
|
|
|
|
|
ViewMode::Breakout => "breakout",
|
|
|
|
|
},
|
|
|
|
|
self.remote_active,
|
2026-04-14 23:03:18 -03:00
|
|
|
if self.capture_power.enabled {
|
|
|
|
|
"on"
|
|
|
|
|
} else {
|
|
|
|
|
"off"
|
|
|
|
|
},
|
2026-04-14 20:05:26 -03:00
|
|
|
self.displays[0].label(),
|
|
|
|
|
self.displays[1].label(),
|
2026-04-13 23:11:35 -03:00
|
|
|
self.devices.camera.as_deref().unwrap_or("auto"),
|
|
|
|
|
self.devices.microphone.as_deref().unwrap_or("auto"),
|
|
|
|
|
self.devices.speaker.as_deref().unwrap_or("auto"),
|
2026-04-15 04:44:06 -03:00
|
|
|
self.swap_key,
|
2026-04-13 23:11:35 -03:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_selection(value: Option<String>) -> Option<String> {
|
|
|
|
|
value.and_then(|v| {
|
|
|
|
|
let trimmed = v.trim();
|
|
|
|
|
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(trimmed.to_string())
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 04:44:06 -03:00
|
|
|
fn normalize_swap_key(value: String) -> String {
|
|
|
|
|
let trimmed = value.trim();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
"off".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
trimmed.to_ascii_lowercase()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn routing_and_view_env_values_are_stable() {
|
|
|
|
|
assert_eq!(InputRouting::Local.as_env(), "0");
|
|
|
|
|
assert_eq!(InputRouting::Remote.as_env(), "1");
|
|
|
|
|
assert_eq!(ViewMode::Unified.as_env(), "unified");
|
|
|
|
|
assert_eq!(ViewMode::Breakout.as_env(), "breakout");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-04-14 18:44:40 -03:00
|
|
|
fn defaults_pick_remote_unified_and_inactive_session() {
|
2026-04-13 23:11:35 -03:00
|
|
|
let state = LauncherState::new();
|
2026-04-14 18:44:40 -03:00
|
|
|
assert_eq!(state.routing, InputRouting::Remote);
|
2026-04-14 14:38:03 -03:00
|
|
|
assert_eq!(state.view_mode, ViewMode::Unified);
|
2026-04-14 20:05:26 -03:00
|
|
|
assert_eq!(state.display_surface(0), DisplaySurface::Preview);
|
|
|
|
|
assert_eq!(state.display_surface(1), DisplaySurface::Preview);
|
2026-04-13 23:11:35 -03:00
|
|
|
assert!(!state.remote_active);
|
|
|
|
|
assert!(state.devices.camera.is_none());
|
|
|
|
|
assert!(state.devices.microphone.is_none());
|
|
|
|
|
assert!(state.devices.speaker.is_none());
|
2026-04-14 23:03:18 -03:00
|
|
|
assert_eq!(state.capture_power.unit, "relay.service");
|
|
|
|
|
assert_eq!(state.capture_power.mode, "auto");
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 20:05:26 -03:00
|
|
|
#[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
#[test]
|
|
|
|
|
fn selecting_auto_or_blank_clears_explicit_device() {
|
|
|
|
|
let mut state = LauncherState::new();
|
|
|
|
|
state.select_camera(Some("/dev/video0".to_string()));
|
|
|
|
|
assert_eq!(state.devices.camera.as_deref(), Some("/dev/video0"));
|
|
|
|
|
|
|
|
|
|
state.select_camera(Some("auto".to_string()));
|
|
|
|
|
assert!(state.devices.camera.is_none());
|
|
|
|
|
|
|
|
|
|
state.select_microphone(Some(" ".to_string()));
|
|
|
|
|
assert!(state.devices.microphone.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn catalog_defaults_fill_only_missing_values() {
|
|
|
|
|
let mut state = LauncherState::new();
|
|
|
|
|
state.select_camera(Some("/dev/video-special".to_string()));
|
|
|
|
|
|
|
|
|
|
let catalog = DeviceCatalog {
|
|
|
|
|
cameras: vec!["/dev/video0".to_string()],
|
|
|
|
|
microphones: vec!["alsa_input.usb".to_string()],
|
|
|
|
|
speakers: vec!["alsa_output.usb".to_string()],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
state.apply_catalog_defaults(&catalog);
|
|
|
|
|
|
|
|
|
|
assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special"));
|
|
|
|
|
assert_eq!(state.devices.microphone.as_deref(), Some("alsa_input.usb"));
|
|
|
|
|
assert_eq!(state.devices.speaker.as_deref(), Some("alsa_output.usb"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn start_and_stop_remote_only_report_changes_once() {
|
|
|
|
|
let mut state = LauncherState::new();
|
|
|
|
|
assert!(state.start_remote());
|
|
|
|
|
assert!(!state.start_remote());
|
|
|
|
|
assert!(state.remote_active);
|
|
|
|
|
|
|
|
|
|
assert!(state.stop_remote());
|
|
|
|
|
assert!(!state.stop_remote());
|
|
|
|
|
assert!(!state.remote_active);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn status_line_mentions_all_user_visible_controls() {
|
|
|
|
|
let mut state = LauncherState::new();
|
|
|
|
|
state.set_routing(InputRouting::Local);
|
|
|
|
|
state.set_view_mode(ViewMode::Unified);
|
|
|
|
|
state.select_camera(Some("/dev/video0".to_string()));
|
|
|
|
|
state.select_microphone(Some("alsa_input.usb".to_string()));
|
|
|
|
|
state.select_speaker(Some("alsa_output.usb".to_string()));
|
|
|
|
|
state.start_remote();
|
|
|
|
|
|
|
|
|
|
let status = state.status_line();
|
|
|
|
|
assert!(status.contains("mode=local"));
|
|
|
|
|
assert!(status.contains("view=unified"));
|
|
|
|
|
assert!(status.contains("active=true"));
|
2026-04-14 20:05:26 -03:00
|
|
|
assert!(status.contains("d1=preview"));
|
|
|
|
|
assert!(status.contains("d2=preview"));
|
2026-04-13 23:11:35 -03:00
|
|
|
assert!(status.contains("camera=/dev/video0"));
|
|
|
|
|
assert!(status.contains("mic=alsa_input.usb"));
|
|
|
|
|
assert!(status.contains("speaker=alsa_output.usb"));
|
|
|
|
|
}
|
2026-04-14 23:03:18 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn capture_power_status_updates_snapshot_state() {
|
|
|
|
|
let mut state = LauncherState::new();
|
|
|
|
|
state.set_capture_power(CapturePowerStatus {
|
|
|
|
|
available: true,
|
|
|
|
|
enabled: true,
|
|
|
|
|
unit: "relay.service".to_string(),
|
|
|
|
|
detail: "active/running".to_string(),
|
|
|
|
|
active_leases: 2,
|
|
|
|
|
mode: "forced-on".to_string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert!(state.capture_power.available);
|
|
|
|
|
assert!(state.capture_power.enabled);
|
|
|
|
|
assert_eq!(state.capture_power.active_leases, 2);
|
|
|
|
|
assert!(state.status_line().contains("power=on"));
|
|
|
|
|
}
|
2026-04-15 04:44:06 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn swap_key_binding_tracks_selected_key_and_binding_mode() {
|
|
|
|
|
let mut state = LauncherState::new();
|
|
|
|
|
assert_eq!(state.swap_key, "pause");
|
|
|
|
|
assert!(!state.swap_key_binding);
|
|
|
|
|
|
|
|
|
|
state.begin_swap_key_binding();
|
|
|
|
|
assert!(state.swap_key_binding);
|
|
|
|
|
|
|
|
|
|
state.set_swap_key("F8");
|
|
|
|
|
assert_eq!(state.swap_key, "f8");
|
|
|
|
|
|
|
|
|
|
state.set_swap_key(" ");
|
|
|
|
|
assert_eq!(state.swap_key, "off");
|
|
|
|
|
|
|
|
|
|
state.finish_swap_key_binding();
|
|
|
|
|
assert!(!state.swap_key_binding);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn push_note_accumulates_operator_context() {
|
|
|
|
|
let mut state = LauncherState::new();
|
|
|
|
|
state.push_note("preview warm");
|
|
|
|
|
state.push_note("relay linked");
|
|
|
|
|
|
|
|
|
|
assert_eq!(state.notes, vec!["preview warm", "relay linked"]);
|
|
|
|
|
}
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|