2026-04-13 23:11:35 -03:00
use anyhow ::Result ;
#[ cfg(not(coverage)) ]
use {
super ::devices ::DeviceCatalog ,
2026-04-14 04:02:39 -03:00
super ::diagnostics ::{
DiagnosticsLog , PerformanceSample , SnapshotReport , quality_probe_command ,
} ,
2026-04-13 23:11:35 -03:00
super ::runtime_env_vars ,
super ::state ::{ InputRouting , LauncherState , ViewMode } ,
gtk ::prelude ::* ,
std ::cell ::RefCell ,
std ::process ::{ Child , Command } ,
std ::rc ::Rc ,
std ::time ::{ SystemTime , UNIX_EPOCH } ,
} ;
#[ 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 diagnostics = Rc ::new ( RefCell ::new ( DiagnosticsLog ::new ( 120 ) ) ) ;
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 diagnostics = Rc ::clone ( & diagnostics ) ;
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_label = gtk ::Label ::new ( Some ( & format! ( " Server: {} " , server_addr . as_ref ( ) ) ) ) ;
server_label . set_halign ( gtk ::Align ::Start ) ;
server_label . set_selectable ( true ) ;
root . append ( & server_label ) ;
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 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 ( " Stop Session " ) ;
2026-04-14 04:02:39 -03:00
let view_toggle_button = gtk ::Button ::with_label ( " " ) ;
let input_toggle_button = gtk ::Button ::with_label ( " " ) ;
2026-04-13 23:11:35 -03:00
let snapshot_button = gtk ::Button ::with_label ( " Save Snapshot " ) ;
button_row . append ( & start_button ) ;
button_row . append ( & stop_button ) ;
2026-04-14 04:02:39 -03:00
button_row . append ( & view_toggle_button ) ;
button_row . append ( & input_toggle_button ) ;
2026-04-13 23:11:35 -03:00
button_row . append ( & snapshot_button ) ;
2026-04-14 04:02:39 -03:00
sync_toggle_button_labels ( & state . borrow ( ) , & view_toggle_button , & input_toggle_button ) ;
2026-04-13 23:11:35 -03:00
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 (
2026-04-14 04:02:39 -03:00
" Unified mode renders both streams side-by-side in one window. Use Pop Out Windows to split back into full windows. Quick input toggle key defaults to Scroll Lock. " ,
2026-04-13 23:11:35 -03:00
) ) ;
note . set_wrap ( true ) ;
note . set_halign ( gtk ::Align ::Start ) ;
root . append ( & note ) ;
{
let state = Rc ::clone ( & state ) ;
let diagnostics = Rc ::clone ( & diagnostics ) ;
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 server_addr = Rc ::clone ( & server_addr ) ;
2026-04-14 04:02:39 -03:00
let view_toggle_button = view_toggle_button . clone ( ) ;
let input_toggle_button = input_toggle_button . clone ( ) ;
2026-04-13 23:11:35 -03:00
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 ) ) ;
}
2026-04-14 04:02:39 -03:00
sync_toggle_button_labels (
& state . borrow ( ) ,
& view_toggle_button ,
& input_toggle_button ,
) ;
2026-04-13 23:11:35 -03:00
if child_proc . borrow ( ) . is_some ( ) {
status_label . set_text ( " Session already running " ) ;
return ;
}
let spawn_result = {
let mut state = state . borrow_mut ( ) ;
2026-04-14 04:02:39 -03:00
launch_or_restart_client ( & child_proc , server_addr . as_ref ( ) , & mut state )
2026-04-13 23:11:35 -03:00
} ;
match spawn_result {
2026-04-14 04:02:39 -03:00
Ok ( ( ) ) = > {
2026-04-13 23:11:35 -03:00
diagnostics . borrow_mut ( ) . record ( PerformanceSample {
rtt_ms : 0.0 ,
input_latency_ms : 0.0 ,
left_fps : 0.0 ,
right_fps : 0.0 ,
dropped_frames : 0 ,
queue_depth : 0 ,
} ) ;
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 | _ | {
2026-04-14 04:02:39 -03:00
stop_child_process ( & child_proc ) ;
2026-04-13 23:11:35 -03:00
let _ = state . borrow_mut ( ) . stop_remote ( ) ;
status_label . set_text ( " Stopped " ) ;
} ) ;
}
2026-04-14 04:02:39 -03:00
{
let state = Rc ::clone ( & state ) ;
let child_proc = Rc ::clone ( & child_proc ) ;
let diagnostics = Rc ::clone ( & diagnostics ) ;
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 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 = {
let mut state = state . borrow_mut ( ) ;
launch_or_restart_client ( & child_proc , server_addr . as_ref ( ) , & mut state )
} ;
match spawn_result {
Ok ( ( ) ) = > {
diagnostics . borrow_mut ( ) . record ( PerformanceSample {
rtt_ms : 0.0 ,
input_latency_ms : 0.0 ,
left_fps : 0.0 ,
right_fps : 0.0 ,
dropped_frames : 0 ,
queue_depth : 0 ,
} ) ;
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 diagnostics = Rc ::clone ( & diagnostics ) ;
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 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 = {
let mut state = state . borrow_mut ( ) ;
launch_or_restart_client ( & child_proc , server_addr . as_ref ( ) , & mut state )
} ;
match spawn_result {
Ok ( ( ) ) = > {
diagnostics . borrow_mut ( ) . record ( PerformanceSample {
rtt_ms : 0.0 ,
input_latency_ms : 0.0 ,
left_fps : 0.0 ,
right_fps : 0.0 ,
dropped_frames : 0 ,
queue_depth : 0 ,
} ) ;
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 ( ) ) ) ;
}
} ) ;
}
2026-04-13 23:11:35 -03:00
{
let state = Rc ::clone ( & state ) ;
let diagnostics = Rc ::clone ( & diagnostics ) ;
let status_label = status_label . clone ( ) ;
snapshot_button . connect_clicked ( move | _ | {
let report = SnapshotReport ::from_state (
& state . borrow ( ) ,
& diagnostics . borrow ( ) ,
quality_probe_command ( ) . to_string ( ) ,
) ;
let json = match report . to_pretty_json ( ) {
Ok ( json ) = > json ,
Err ( err ) = > {
status_label . set_text ( & format! ( " Snapshot failed: {err} " ) ) ;
return ;
}
} ;
let path = format! ( " /tmp/lesavka-launcher-snapshot- {} .json " , now_unix_seconds ( ) ) ;
match std ::fs ::write ( & path , json ) {
Ok ( ( ) ) = > {
state . borrow_mut ( ) . push_note ( format! ( " snapshot= {path} " ) ) ;
status_label . set_text ( & format! ( " Snapshot written: {path} " ) ) ;
}
Err ( err ) = > {
status_label . set_text ( & format! ( " Snapshot write failed: {err} " ) ) ;
}
}
} ) ;
}
window . set_child ( Some ( & root ) ) ;
window . present ( ) ;
} ) ;
}
2026-04-14 08:16:57 -03:00
let _ = app . run_with_args ::< & str > ( & [ ] ) ;
2026-04-13 23:11:35 -03:00
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 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 ) -> 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 ) ;
for ( key , value ) in runtime_env_vars ( state ) {
command . env ( key , value ) ;
}
Ok ( command . spawn ( ) ? )
}
2026-04-14 04:02:39 -03:00
#[ 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 ,
) -> Result < ( ) > {
stop_child_process ( child_proc ) ;
let _ = state . start_remote ( ) ;
let child = spawn_client_process ( server_addr , state ) ? ;
* 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 " ,
} ) ;
}
2026-04-13 23:11:35 -03:00
#[ cfg(not(coverage)) ]
fn now_unix_seconds ( ) -> u64 {
SystemTime ::now ( )
. duration_since ( UNIX_EPOCH )
. map ( | d | d . as_secs ( ) )
. unwrap_or ( 0 )
}
#[ 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 ( ) ) ;
}
}