566 lines
22 KiB
Rust
Raw Normal View History

use anyhow::{Result, anyhow};
#[cfg(not(coverage))]
use {
super::devices::DeviceCatalog,
super::diagnostics::quality_probe_command,
super::runtime_env_vars,
super::state::{InputRouting, LauncherState, ViewMode},
crate::paste,
gtk::prelude::*,
lesavka_common::lesavka::relay_client::RelayClient,
std::cell::RefCell,
std::process::{Child, Command},
std::rc::Rc,
tokio::runtime::Builder as RuntimeBuilder,
tonic::{Request, transport::Channel},
};
#[cfg(not(coverage))]
pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let app = gtk::Application::builder()
.application_id("dev.lesavka.launcher")
.build();
let catalog = Rc::new(DeviceCatalog::discover());
let state = Rc::new(RefCell::new(LauncherState::new()));
state.borrow_mut().apply_catalog_defaults(&catalog);
let child_proc = Rc::new(RefCell::new(None::<Child>));
let server_addr = Rc::new(server_addr);
{
let child_proc = Rc::clone(&child_proc);
app.connect_shutdown(move |_| {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
});
}
{
let catalog = Rc::clone(&catalog);
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let server_addr = Rc::clone(&server_addr);
app.connect_activate(move |app| {
let window = gtk::ApplicationWindow::builder()
.application(app)
.title("Lesavka Launcher")
.default_width(680)
.default_height(520)
.build();
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
root.set_margin_start(14);
root.set_margin_end(14);
root.set_margin_top(14);
root.set_margin_bottom(14);
let heading = gtk::Label::new(Some("Lesavka Session Launcher"));
heading.add_css_class("title-2");
heading.set_halign(gtk::Align::Start);
root.append(&heading);
let status_label = gtk::Label::new(Some("Idle"));
status_label.set_halign(gtk::Align::Start);
status_label.set_selectable(true);
root.append(&status_label);
let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let server_label = gtk::Label::new(Some("Server"));
server_label.set_halign(gtk::Align::Start);
let server_entry = gtk::Entry::new();
server_entry.set_hexpand(true);
server_entry.set_text(server_addr.as_ref());
server_row.append(&server_label);
server_row.append(&server_entry);
root.append(&server_row);
let controls = gtk::Grid::new();
controls.set_row_spacing(8);
controls.set_column_spacing(8);
root.append(&controls);
let routing_label = gtk::Label::new(Some("Remote input capture"));
routing_label.set_halign(gtk::Align::Start);
controls.attach(&routing_label, 0, 0, 1, 1);
let routing_switch = gtk::Switch::new();
routing_switch.set_active(matches!(state.borrow().routing, InputRouting::Remote));
controls.attach(&routing_switch, 1, 0, 1, 1);
let view_label = gtk::Label::new(Some("View mode"));
view_label.set_halign(gtk::Align::Start);
controls.attach(&view_label, 0, 1, 1, 1);
let view_combo = gtk::ComboBoxText::new();
view_combo.append(Some("unified"), "unified");
view_combo.append(Some("breakout"), "breakout");
view_combo.set_active(Some(match state.borrow().view_mode {
ViewMode::Unified => 0,
ViewMode::Breakout => 1,
}));
controls.attach(&view_combo, 1, 1, 1, 1);
let camera_label = gtk::Label::new(Some("Camera"));
camera_label.set_halign(gtk::Align::Start);
controls.attach(&camera_label, 0, 2, 1, 1);
let camera_combo = gtk::ComboBoxText::new();
camera_combo.append(Some("auto"), "auto");
for camera in &catalog.cameras {
camera_combo.append(Some(camera), camera);
}
set_combo_active_text(&camera_combo, state.borrow().devices.camera.as_deref());
controls.attach(&camera_combo, 1, 2, 1, 1);
let microphone_label = gtk::Label::new(Some("Microphone"));
microphone_label.set_halign(gtk::Align::Start);
controls.attach(&microphone_label, 0, 3, 1, 1);
let microphone_combo = gtk::ComboBoxText::new();
microphone_combo.append(Some("auto"), "auto");
for microphone in &catalog.microphones {
microphone_combo.append(Some(microphone), microphone);
}
set_combo_active_text(
&microphone_combo,
state.borrow().devices.microphone.as_deref(),
);
controls.attach(&microphone_combo, 1, 3, 1, 1);
let speaker_label = gtk::Label::new(Some("Speaker"));
speaker_label.set_halign(gtk::Align::Start);
controls.attach(&speaker_label, 0, 4, 1, 1);
let speaker_combo = gtk::ComboBoxText::new();
speaker_combo.append(Some("auto"), "auto");
for speaker in &catalog.speakers {
speaker_combo.append(Some(speaker), speaker);
}
set_combo_active_text(&speaker_combo, state.borrow().devices.speaker.as_deref());
controls.attach(&speaker_combo, 1, 4, 1, 1);
let toggle_key_label = gtk::Label::new(Some("Input swap key"));
toggle_key_label.set_halign(gtk::Align::Start);
controls.attach(&toggle_key_label, 0, 5, 1, 1);
let toggle_key_combo = gtk::ComboBoxText::new();
toggle_key_combo.append(Some("scrolllock"), "Scroll Lock");
toggle_key_combo.append(Some("sysrq"), "SysRq / PrtSc");
toggle_key_combo.append(Some("pause"), "Pause");
toggle_key_combo.append(Some("f12"), "F12");
toggle_key_combo.append(Some("f11"), "F11");
toggle_key_combo.append(Some("f10"), "F10");
toggle_key_combo.append(Some("off"), "Disabled");
let _ = toggle_key_combo.set_active_id(Some("pause"));
controls.attach(&toggle_key_combo, 1, 5, 1, 1);
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
root.append(&button_row);
let start_button = gtk::Button::with_label("Start Session");
let stop_button = gtk::Button::with_label("End Relay");
let view_toggle_button = gtk::Button::with_label("");
let input_toggle_button = gtk::Button::with_label("");
let clipboard_button = gtk::Button::with_label("Send Clipboard");
button_row.append(&start_button);
button_row.append(&stop_button);
button_row.append(&view_toggle_button);
button_row.append(&input_toggle_button);
button_row.append(&clipboard_button);
sync_toggle_button_labels(&state.borrow(), &view_toggle_button, &input_toggle_button);
let probe_hint = gtk::Label::new(Some(quality_probe_command()));
probe_hint.set_halign(gtk::Align::Start);
probe_hint.set_selectable(true);
root.append(&probe_hint);
let note = gtk::Label::new(Some(
"Unified mode renders both streams side-by-side in one window. Use Pop Out Windows to split back into full windows. Input swap key defaults to Pause and can be changed.",
));
note.set_wrap(true);
note.set_halign(gtk::Align::Start);
root.append(&note);
{
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let status_label = status_label.clone();
let routing_switch = routing_switch.clone();
let view_combo = view_combo.clone();
let camera_combo = camera_combo.clone();
let microphone_combo = microphone_combo.clone();
let speaker_combo = speaker_combo.clone();
let toggle_key_combo = toggle_key_combo.clone();
let server_entry = server_entry.clone();
let server_addr = Rc::clone(&server_addr);
let view_toggle_button = view_toggle_button.clone();
let input_toggle_button = input_toggle_button.clone();
start_button.connect_clicked(move |_| {
{
let mut state = state.borrow_mut();
let routing = if routing_switch.is_active() {
InputRouting::Remote
} else {
InputRouting::Local
};
state.set_routing(routing);
state.set_view_mode(if view_combo.active() == Some(0) {
ViewMode::Unified
} else {
ViewMode::Breakout
});
state.select_camera(selected_combo_value(&camera_combo));
state.select_microphone(selected_combo_value(&microphone_combo));
state.select_speaker(selected_combo_value(&speaker_combo));
}
sync_toggle_button_labels(
&state.borrow(),
&view_toggle_button,
&input_toggle_button,
);
if child_proc.borrow().is_some() {
status_label.set_text("Session already running");
return;
}
let spawn_result = relaunch_with_settings(
&child_proc,
&state,
&server_entry,
server_addr.as_ref(),
&toggle_key_combo,
);
match spawn_result {
Ok(()) => status_label.set_text(&format!("Started: {}", state.borrow().status_line())),
Err(err) => {
let _ = state.borrow_mut().stop_remote();
status_label.set_text(&format!("Start failed: {err}"));
}
}
});
}
{
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let status_label = status_label.clone();
stop_button.connect_clicked(move |_| {
stop_child_process(&child_proc);
let _ = state.borrow_mut().stop_remote();
status_label.set_text("Relay ended");
});
}
{
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let status_label = status_label.clone();
let view_combo = view_combo.clone();
let input_toggle_button = input_toggle_button.clone();
let view_toggle_button = view_toggle_button.clone();
let toggle_key_combo = toggle_key_combo.clone();
let server_entry = server_entry.clone();
let server_addr = Rc::clone(&server_addr);
let view_toggle_button_handle = view_toggle_button.clone();
view_toggle_button_handle.connect_clicked(move |_| {
{
let mut state = state.borrow_mut();
let next = next_view_mode(state.view_mode);
state.set_view_mode(next);
let _ = view_combo.set_active_id(Some(state.view_mode.as_env()));
}
sync_toggle_button_labels(
&state.borrow(),
&view_toggle_button,
&input_toggle_button,
);
if child_proc.borrow().is_some() {
let spawn_result = relaunch_with_settings(
&child_proc,
&state,
&server_entry,
server_addr.as_ref(),
&toggle_key_combo,
);
match spawn_result {
Ok(()) => status_label
.set_text(&format!("View switched live: {}", state.borrow().status_line())),
Err(err) => {
let _ = state.borrow_mut().stop_remote();
status_label.set_text(&format!("View switch failed: {err}"));
}
}
} else {
status_label.set_text(&format!("View ready: {}", state.borrow().status_line()));
}
});
}
{
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let status_label = status_label.clone();
let routing_switch = routing_switch.clone();
let input_toggle_button = input_toggle_button.clone();
let view_toggle_button = view_toggle_button.clone();
let toggle_key_combo = toggle_key_combo.clone();
let server_entry = server_entry.clone();
let server_addr = Rc::clone(&server_addr);
let input_toggle_button_handle = input_toggle_button.clone();
input_toggle_button_handle.connect_clicked(move |_| {
{
let mut state = state.borrow_mut();
let next = next_input_routing(state.routing);
state.set_routing(next);
routing_switch.set_active(matches!(state.routing, InputRouting::Remote));
}
sync_toggle_button_labels(
&state.borrow(),
&view_toggle_button,
&input_toggle_button,
);
if child_proc.borrow().is_some() {
let spawn_result = relaunch_with_settings(
&child_proc,
&state,
&server_entry,
server_addr.as_ref(),
&toggle_key_combo,
);
match spawn_result {
Ok(()) => status_label.set_text(&format!(
"Input mode switched live: {}",
state.borrow().status_line()
)),
Err(err) => {
let _ = state.borrow_mut().stop_remote();
status_label.set_text(&format!("Input switch failed: {err}"));
}
}
} else {
status_label.set_text(&format!("Input ready: {}", state.borrow().status_line()));
}
});
}
{
let child_proc = Rc::clone(&child_proc);
let status_label = status_label.clone();
let server_entry = server_entry.clone();
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");
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}")),
}
});
}
window.set_child(Some(&root));
window.present();
});
}
let _ = app.run_with_args::<&str>(&[]);
Ok(())
}
#[cfg(coverage)]
pub fn run_gui_launcher(_server_addr: String) -> Result<()> {
Ok(())
}
#[cfg(not(coverage))]
fn selected_combo_value(combo: &gtk::ComboBoxText) -> Option<String> {
combo.active_text().and_then(|value| {
let value = value.to_string();
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
None
} else {
Some(trimmed.to_string())
}
})
}
#[cfg(not(coverage))]
fn selected_toggle_key(combo: &gtk::ComboBoxText) -> String {
combo
.active_id()
.map(|value| value.to_string())
.unwrap_or_else(|| "pause".to_string())
}
#[cfg(not(coverage))]
fn selected_server_addr(entry: &gtk::Entry, fallback: &str) -> String {
let current = entry.text();
let trimmed = current.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
#[cfg(not(coverage))]
/// Applies the current server/key launcher controls and relaunches the child session.
fn relaunch_with_settings(
child_proc: &Rc<RefCell<Option<Child>>>,
state: &Rc<RefCell<LauncherState>>,
server_entry: &gtk::Entry,
server_fallback: &str,
toggle_key_combo: &gtk::ComboBoxText,
) -> 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();
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 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);
}
}
None
}
#[cfg(not(coverage))]
fn set_combo_active_text(combo: &gtk::ComboBoxText, wanted: Option<&str>) {
let wanted = wanted.unwrap_or("auto");
if !combo.set_active_id(Some(wanted)) {
let _ = combo.set_active_id(Some("auto"));
}
}
#[cfg(not(coverage))]
fn spawn_client_process(
server_addr: &str,
state: &LauncherState,
input_toggle_key: &str,
) -> Result<Child> {
let exe = std::env::current_exe()?;
let mut command = Command::new(exe);
command.env("LESAVKA_LAUNCHER_CHILD", "1");
command.env("LESAVKA_SERVER_ADDR", server_addr);
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");
for (key, value) in runtime_env_vars(state) {
command.env(key, value);
}
Ok(command.spawn()?)
}
#[cfg(not(coverage))]
/// Stops and reaps the launcher child process when one is running.
fn stop_child_process(child_proc: &Rc<RefCell<Option<Child>>>) {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
}
#[cfg(not(coverage))]
/// Restarts the launcher child process with the current launcher state.
fn launch_or_restart_client(
child_proc: &Rc<RefCell<Option<Child>>>,
server_addr: &str,
state: &mut LauncherState,
input_toggle_key: &str,
) -> Result<()> {
stop_child_process(child_proc);
let _ = state.start_remote();
let child = spawn_client_process(server_addr, state, input_toggle_key)?;
*child_proc.borrow_mut() = Some(child);
Ok(())
}
#[cfg(not(coverage))]
/// Flips between unified preview and breakout windows.
fn next_view_mode(mode: ViewMode) -> ViewMode {
match mode {
ViewMode::Unified => ViewMode::Breakout,
ViewMode::Breakout => ViewMode::Unified,
}
}
#[cfg(not(coverage))]
/// Flips between remote input capture and local input passthrough.
fn next_input_routing(routing: InputRouting) -> InputRouting {
match routing {
InputRouting::Remote => InputRouting::Local,
InputRouting::Local => InputRouting::Remote,
}
}
#[cfg(not(coverage))]
/// Keeps toggle buttons aligned with the launcher's current state.
fn sync_toggle_button_labels(
state: &LauncherState,
view_toggle_button: &gtk::Button,
input_toggle_button: &gtk::Button,
) {
view_toggle_button.set_label(match state.view_mode {
ViewMode::Unified => "Pop Out Windows",
ViewMode::Breakout => "Merge Windows",
});
input_toggle_button.set_label(match state.routing {
InputRouting::Remote => "Use Local Inputs",
InputRouting::Local => "Use Remote Inputs",
});
}
#[cfg(all(test, coverage))]
mod tests {
use super::run_gui_launcher;
#[test]
fn coverage_stub_returns_ok() {
assert!(run_gui_launcher("http://127.0.0.1:50051".to_string()).is_ok());
}
}