2026-04-14 18:44:40 -03:00
use anyhow ::Result ;
2026-04-13 23:11:35 -03:00
#[ cfg(not(coverage)) ]
use {
2026-04-14 18:44:40 -03:00
super ::clipboard ::send_clipboard_to_remote ,
2026-04-13 23:11:35 -03:00
super ::devices ::DeviceCatalog ,
2026-04-14 13:09:25 -03:00
super ::diagnostics ::quality_probe_command ,
2026-04-14 18:44:40 -03:00
super ::launcher_focus_signal_path ,
2026-04-14 20:05:26 -03:00
super ::preview ::{ LauncherPreview , PreviewBinding } ,
2026-04-13 23:11:35 -03:00
super ::runtime_env_vars ,
2026-04-14 20:05:26 -03:00
super ::state ::{ DisplaySurface , InputRouting , LauncherState } ,
2026-04-14 18:44:40 -03:00
super ::LAUNCHER_FOCUS_SIGNAL_ENV ,
gtk ::glib ,
2026-04-14 20:05:26 -03:00
gtk ::prelude ::* ,
2026-04-13 23:11:35 -03:00
std ::cell ::RefCell ,
2026-04-14 20:05:26 -03:00
std ::path ::{ Path , PathBuf } ,
2026-04-13 23:11:35 -03:00
std ::process ::{ Child , Command } ,
std ::rc ::Rc ,
2026-04-14 18:44:40 -03:00
std ::time ::Duration ,
2026-04-13 23:11:35 -03:00
} ;
2026-04-14 20:05:26 -03:00
#[ cfg(not(coverage)) ]
const INPUT_CONTROL_ENV : & str = " LESAVKA_LAUNCHER_INPUT_CONTROL " ;
#[ cfg(not(coverage)) ]
const INPUT_STATE_ENV : & str = " LESAVKA_LAUNCHER_INPUT_STATE " ;
#[ cfg(not(coverage)) ]
const DEFAULT_INPUT_CONTROL_PATH : & str = " /tmp/lesavka-launcher-input.control " ;
#[ cfg(not(coverage)) ]
const DEFAULT_INPUT_STATE_PATH : & str = " /tmp/lesavka-launcher-input.state " ;
#[ cfg(not(coverage)) ]
#[ derive(Clone) ]
struct SummaryWidgets {
relay_value : gtk ::Label ,
routing_value : gtk ::Label ,
displays_value : gtk ::Label ,
shortcut_value : gtk ::Label ,
}
#[ cfg(not(coverage)) ]
#[ derive(Clone) ]
struct DisplayPaneWidgets {
root : gtk ::Box ,
stack : gtk ::Stack ,
picture : gtk ::Picture ,
stream_status : gtk ::Label ,
placeholder : gtk ::Label ,
action_button : gtk ::Button ,
preview_binding : Option < PreviewBinding > ,
title : String ,
}
#[ cfg(not(coverage)) ]
struct PopoutWindowHandle {
window : gtk ::ApplicationWindow ,
binding : PreviewBinding ,
}
#[ cfg(not(coverage)) ]
#[ derive(Clone) ]
struct LauncherWidgets {
status_label : gtk ::Label ,
summary : SummaryWidgets ,
display_panes : [ DisplayPaneWidgets ; 2 ] ,
start_button : gtk ::Button ,
stop_button : gtk ::Button ,
input_toggle_button : gtk ::Button ,
clipboard_button : gtk ::Button ,
toggle_key_combo : gtk ::ComboBoxText ,
}
2026-04-13 23:11:35 -03:00
#[ 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 ) ;
2026-04-14 18:44:40 -03:00
let focus_signal_path = Rc ::new ( launcher_focus_signal_path ( ) ) ;
2026-04-14 20:05:26 -03:00
let input_control_path = Rc ::new ( input_control_path ( ) ) ;
let input_state_path = Rc ::new ( input_state_path ( ) ) ;
2026-04-14 18:44:40 -03:00
let _ = std ::fs ::remove_file ( focus_signal_path . as_path ( ) ) ;
2026-04-14 20:05:26 -03:00
let _ = std ::fs ::remove_file ( input_control_path . as_path ( ) ) ;
let _ = std ::fs ::remove_file ( input_state_path . as_path ( ) ) ;
2026-04-13 23:11:35 -03:00
{
let child_proc = Rc ::clone ( & child_proc ) ;
2026-04-14 20:05:26 -03:00
let focus_signal_path = Rc ::clone ( & focus_signal_path ) ;
let input_control_path = Rc ::clone ( & input_control_path ) ;
let input_state_path = Rc ::clone ( & input_state_path ) ;
2026-04-13 23:11:35 -03:00
app . connect_shutdown ( move | _ | {
2026-04-14 20:05:26 -03:00
stop_child_process ( & child_proc ) ;
let _ = std ::fs ::remove_file ( focus_signal_path . as_path ( ) ) ;
let _ = std ::fs ::remove_file ( input_control_path . as_path ( ) ) ;
let _ = std ::fs ::remove_file ( input_state_path . as_path ( ) ) ;
2026-04-13 23:11:35 -03:00
} ) ;
}
{
let catalog = Rc ::clone ( & catalog ) ;
let state = Rc ::clone ( & state ) ;
let child_proc = Rc ::clone ( & child_proc ) ;
let server_addr = Rc ::clone ( & server_addr ) ;
2026-04-14 18:44:40 -03:00
let focus_signal_path = Rc ::clone ( & focus_signal_path ) ;
2026-04-14 20:05:26 -03:00
let input_control_path = Rc ::clone ( & input_control_path ) ;
let input_state_path = Rc ::clone ( & input_state_path ) ;
2026-04-13 23:11:35 -03:00
app . connect_activate ( move | app | {
let window = gtk ::ApplicationWindow ::builder ( )
. application ( app )
. title ( " Lesavka Launcher " )
2026-04-14 20:05:26 -03:00
. default_width ( 1120 )
. default_height ( 920 )
2026-04-13 23:11:35 -03:00
. build ( ) ;
2026-04-14 14:38:03 -03:00
let scroll = gtk ::ScrolledWindow ::new ( ) ;
scroll . set_policy ( gtk ::PolicyType ::Never , gtk ::PolicyType ::Automatic ) ;
2026-04-14 20:05:26 -03:00
let root = gtk ::Box ::new ( gtk ::Orientation ::Vertical , 14 ) ;
root . set_margin_start ( 18 ) ;
root . set_margin_end ( 18 ) ;
root . set_margin_top ( 18 ) ;
root . set_margin_bottom ( 18 ) ;
2026-04-13 23:11:35 -03:00
2026-04-14 20:05:26 -03:00
let heading = gtk ::Label ::new ( Some ( " Lesavka Control Deck " ) ) ;
2026-04-13 23:11:35 -03:00
heading . add_css_class ( " title-2 " ) ;
heading . set_halign ( gtk ::Align ::Start ) ;
root . append ( & heading ) ;
2026-04-14 20:05:26 -03:00
let status_label = gtk ::Label ::new ( Some (
" Launcher ready - previews stay here, relay starts only when you ask for it. " ,
) ) ;
2026-04-13 23:11:35 -03:00
status_label . set_halign ( gtk ::Align ::Start ) ;
2026-04-14 20:05:26 -03:00
status_label . set_wrap ( true ) ;
2026-04-13 23:11:35 -03:00
status_label . set_selectable ( true ) ;
root . append ( & status_label ) ;
2026-04-14 20:05:26 -03:00
let ( summary_frame , summary_box ) = build_section ( " Session Status " ) ;
let summary_grid = gtk ::Grid ::new ( ) ;
summary_grid . set_row_spacing ( 8 ) ;
summary_grid . set_column_spacing ( 12 ) ;
summary_box . append ( & summary_grid ) ;
let summary = SummaryWidgets {
relay_value : attach_summary_row ( & summary_grid , 0 , " Relay " , " Stopped " ) ,
routing_value : attach_summary_row ( & summary_grid , 1 , " Input Target " , " Remote " ) ,
displays_value : attach_summary_row (
& summary_grid ,
2 ,
" Displays " ,
" Display 1: preview | Display 2: preview " ,
) ,
shortcut_value : attach_summary_row ( & summary_grid , 3 , " Swap Key " , " Pause " ) ,
} ;
root . append ( & summary_frame ) ;
2026-04-14 14:38:03 -03:00
2026-04-14 20:05:26 -03:00
let ( connection_frame , connection_box ) = build_section ( " Connection " ) ;
let server_row = gtk ::Box ::new ( gtk ::Orientation ::Horizontal , 10 ) ;
2026-04-14 13:09:25 -03:00
let server_label = gtk ::Label ::new ( Some ( " Server " ) ) ;
2026-04-13 23:11:35 -03:00
server_label . set_halign ( gtk ::Align ::Start ) ;
2026-04-14 13:09:25 -03:00
let server_entry = gtk ::Entry ::new ( ) ;
server_entry . set_hexpand ( true ) ;
server_entry . set_text ( server_addr . as_ref ( ) ) ;
2026-04-14 20:05:26 -03:00
let start_button = gtk ::Button ::with_label ( " Start Relay " ) ;
let stop_button = gtk ::Button ::with_label ( " Stop Relay " ) ;
2026-04-14 13:09:25 -03:00
server_row . append ( & server_label ) ;
server_row . append ( & server_entry ) ;
2026-04-14 20:05:26 -03:00
server_row . append ( & start_button ) ;
server_row . append ( & stop_button ) ;
connection_box . append ( & server_row ) ;
let connection_note = gtk ::Label ::new ( Some (
" Starting relay launches the live input/audio session to the remote host. Stopping relay severs that session cleanly. " ,
) ) ;
connection_note . set_wrap ( true ) ;
connection_note . set_halign ( gtk ::Align ::Start ) ;
connection_box . append ( & connection_note ) ;
root . append ( & connection_frame ) ;
let ( inputs_frame , inputs_box ) = build_section ( " Input Routing And Devices " ) ;
let inputs_grid = gtk ::Grid ::new ( ) ;
inputs_grid . set_row_spacing ( 8 ) ;
inputs_grid . set_column_spacing ( 12 ) ;
inputs_box . append ( & inputs_grid ) ;
let input_toggle_button = gtk ::Button ::with_label ( " Switch To Local Inputs " ) ;
inputs_grid . attach ( & gtk ::Label ::new ( Some ( " Live Input Target " ) ) , 0 , 0 , 1 , 1 ) ;
inputs_grid . attach ( & input_toggle_button , 1 , 0 , 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 " ) ) ;
inputs_grid . attach ( & gtk ::Label ::new ( Some ( " Swap Key " ) ) , 0 , 1 , 1 , 1 ) ;
inputs_grid . attach ( & toggle_key_combo , 1 , 1 , 1 , 1 ) ;
2026-04-13 23:11:35 -03:00
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 ( ) ) ;
2026-04-14 20:05:26 -03:00
inputs_grid . attach ( & gtk ::Label ::new ( Some ( " Camera " ) ) , 0 , 2 , 1 , 1 ) ;
inputs_grid . attach ( & camera_combo , 1 , 2 , 1 , 1 ) ;
2026-04-13 23:11:35 -03:00
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 ( ) ,
) ;
2026-04-14 20:05:26 -03:00
inputs_grid . attach ( & gtk ::Label ::new ( Some ( " Microphone " ) ) , 0 , 3 , 1 , 1 ) ;
inputs_grid . attach ( & microphone_combo , 1 , 3 , 1 , 1 ) ;
2026-04-13 23:11:35 -03:00
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 ( ) ) ;
2026-04-14 20:05:26 -03:00
inputs_grid . attach ( & gtk ::Label ::new ( Some ( " Speaker " ) ) , 0 , 4 , 1 , 1 ) ;
inputs_grid . attach ( & speaker_combo , 1 , 4 , 1 , 1 ) ;
2026-04-14 13:09:25 -03:00
2026-04-14 20:05:26 -03:00
let inputs_note = gtk ::Label ::new ( Some (
" Press the swap key while relay is running to flip between local and remote input ownership. The launcher reflects that live state and macros stay launcher-only. " ,
) ) ;
inputs_note . set_wrap ( true ) ;
inputs_note . set_halign ( gtk ::Align ::Start ) ;
inputs_box . append ( & inputs_note ) ;
root . append ( & inputs_frame ) ;
let ( displays_frame , displays_box ) = build_section ( " Displays " ) ;
let display_row = gtk ::Box ::new ( gtk ::Orientation ::Horizontal , 14 ) ;
let left_pane = build_display_pane ( " Display 1 " ) ;
let right_pane = build_display_pane ( " Display 2 " ) ;
display_row . append ( & left_pane . root ) ;
display_row . append ( & right_pane . root ) ;
displays_box . append ( & display_row ) ;
root . append ( & displays_frame ) ;
let ( actions_frame , actions_box ) = build_section ( " Remote Actions " ) ;
2026-04-14 13:09:25 -03:00
let clipboard_button = gtk ::Button ::with_label ( " Send Clipboard " ) ;
2026-04-14 20:05:26 -03:00
actions_box . append ( & clipboard_button ) ;
let actions_note = gtk ::Label ::new ( Some (
" Clipboard paste is a launcher action only. It types the current local clipboard into the remote target machine when relay is active. " ,
) ) ;
actions_note . set_wrap ( true ) ;
actions_note . set_halign ( gtk ::Align ::Start ) ;
actions_box . append ( & actions_note ) ;
root . append ( & actions_frame ) ;
2026-04-13 23:11:35 -03:00
2026-04-14 20:05:26 -03:00
let ( diagnostics_frame , diagnostics_box ) = build_section ( " Diagnostics " ) ;
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 ) ;
2026-04-14 20:05:26 -03:00
diagnostics_box . append ( & probe_hint ) ;
let diagnostics_note = gtk ::Label ::new ( Some (
" Keep the hygiene and quality gates green before calling the launcher changes done. Metrics still land in the local Prometheus textfile output. " ,
2026-04-13 23:11:35 -03:00
) ) ;
2026-04-14 20:05:26 -03:00
diagnostics_note . set_wrap ( true ) ;
diagnostics_note . set_halign ( gtk ::Align ::Start ) ;
diagnostics_box . append ( & diagnostics_note ) ;
root . append ( & diagnostics_frame ) ;
2026-04-13 23:11:35 -03:00
2026-04-14 18:44:40 -03:00
let preview = match LauncherPreview ::new ( server_addr . as_ref ( ) . to_string ( ) ) {
2026-04-14 20:05:26 -03:00
Ok ( preview ) = > Some ( Rc ::new ( preview ) ) ,
2026-04-14 14:38:03 -03:00
Err ( err ) = > {
let msg = format! ( " Preview unavailable: {err} " ) ;
2026-04-14 20:05:26 -03:00
status_label . set_text ( & msg ) ;
2026-04-14 18:44:40 -03:00
None
2026-04-14 14:38:03 -03:00
}
2026-04-14 18:44:40 -03:00
} ;
2026-04-14 20:05:26 -03:00
let mut left_pane = left_pane ;
let mut right_pane = right_pane ;
if let Some ( preview ) = preview . as_ref ( ) {
left_pane . preview_binding =
preview . install_on_picture ( 0 , & left_pane . picture , & left_pane . stream_status ) ;
right_pane . preview_binding =
preview . install_on_picture ( 1 , & right_pane . picture , & right_pane . stream_status ) ;
} else {
left_pane . stream_status . set_text ( " Preview unavailable " ) ;
right_pane . stream_status . set_text ( " Preview unavailable " ) ;
}
let widgets = LauncherWidgets {
status_label : status_label . clone ( ) ,
summary ,
display_panes : [ left_pane . clone ( ) , right_pane . clone ( ) ] ,
start_button : start_button . clone ( ) ,
stop_button : stop_button . clone ( ) ,
input_toggle_button : input_toggle_button . clone ( ) ,
clipboard_button : clipboard_button . clone ( ) ,
toggle_key_combo : toggle_key_combo . clone ( ) ,
} ;
let popouts = Rc ::new ( RefCell ::new ( [ None , None ] ) ) ;
refresh_launcher_ui ( & widgets , & state . borrow ( ) , child_proc . borrow ( ) . is_some ( ) ) ;
2026-04-14 18:44:40 -03:00
{
2026-04-14 20:05:26 -03:00
let widgets = widgets . clone ( ) ;
2026-04-14 18:44:40 -03:00
let state = Rc ::clone ( & state ) ;
2026-04-14 20:05:26 -03:00
let child_proc = Rc ::clone ( & child_proc ) ;
toggle_key_combo . connect_changed ( move | _ | {
refresh_launcher_ui ( & widgets , & state . borrow ( ) , child_proc . borrow ( ) . is_some ( ) ) ;
2026-04-14 18:44:40 -03:00
} ) ;
2026-04-14 14:38:03 -03:00
}
2026-04-13 23:11:35 -03:00
{
let state = Rc ::clone ( & state ) ;
let child_proc = Rc ::clone ( & child_proc ) ;
2026-04-14 20:05:26 -03:00
let widgets = widgets . clone ( ) ;
let server_entry = server_entry . clone ( ) ;
2026-04-13 23:11:35 -03:00
let camera_combo = camera_combo . clone ( ) ;
let microphone_combo = microphone_combo . clone ( ) ;
let speaker_combo = speaker_combo . clone ( ) ;
2026-04-14 20:05:26 -03:00
let input_control_path = Rc ::clone ( & input_control_path ) ;
let input_state_path = Rc ::clone ( & input_state_path ) ;
let server_addr_fallback = Rc ::clone ( & server_addr ) ;
2026-04-13 23:11:35 -03:00
start_button . connect_clicked ( move | _ | {
2026-04-14 20:05:26 -03:00
if child_proc . borrow ( ) . is_some ( ) {
widgets . status_label . set_text ( " Relay is already running " ) ;
refresh_launcher_ui ( & widgets , & state . borrow ( ) , true ) ;
return ;
}
2026-04-13 23:11:35 -03:00
{
let mut state = state . borrow_mut ( ) ;
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 20:05:26 -03:00
let _ = std ::fs ::remove_file ( input_control_path . as_path ( ) ) ;
let _ = std ::fs ::remove_file ( input_state_path . as_path ( ) ) ;
let server_addr =
selected_server_addr ( & server_entry , server_addr_fallback . as_ref ( ) ) ;
let launch_state = state . borrow ( ) . clone ( ) ;
let input_toggle_key = selected_toggle_key ( & widgets . toggle_key_combo ) ;
match spawn_client_process (
& server_addr ,
& launch_state ,
& input_toggle_key ,
input_control_path . as_path ( ) ,
input_state_path . as_path ( ) ,
) {
Ok ( child ) = > {
* child_proc . borrow_mut ( ) = Some ( child ) ;
let _ = state . borrow_mut ( ) . start_remote ( ) ;
let routing = state . borrow ( ) . routing ;
widgets . status_label . set_text ( & format! (
" Relay started - input target is {} " ,
routing_name ( routing )
) ) ;
2026-04-14 18:44:40 -03:00
}
2026-04-13 23:11:35 -03:00
Err ( err ) = > {
2026-04-14 20:05:26 -03:00
widgets
. status_label
. set_text ( & format! ( " Relay start failed: {err} " ) ) ;
2026-04-13 23:11:35 -03:00
}
}
2026-04-14 20:05:26 -03:00
refresh_launcher_ui ( & widgets , & state . borrow ( ) , child_proc . borrow ( ) . is_some ( ) ) ;
2026-04-13 23:11:35 -03:00
} ) ;
}
{
let state = Rc ::clone ( & state ) ;
let child_proc = Rc ::clone ( & child_proc ) ;
2026-04-14 20:05:26 -03:00
let widgets = widgets . clone ( ) ;
2026-04-13 23:11:35 -03:00
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 ( ) ;
2026-04-14 20:05:26 -03:00
widgets . status_label . set_text ( " Relay stopped " ) ;
refresh_launcher_ui ( & widgets , & state . borrow ( ) , false ) ;
2026-04-14 04:02:39 -03:00
} ) ;
}
{
let state = Rc ::clone ( & state ) ;
let child_proc = Rc ::clone ( & child_proc ) ;
2026-04-14 20:05:26 -03:00
let widgets = widgets . clone ( ) ;
let input_control_path = Rc ::clone ( & input_control_path ) ;
input_toggle_button . connect_clicked ( move | _ | {
let next = next_input_routing ( state . borrow ( ) . routing ) ;
let child_running = child_proc . borrow ( ) . is_some ( ) ;
if child_running {
if let Err ( err ) =
write_input_routing_request ( input_control_path . as_path ( ) , next )
{
widgets . status_label . set_text ( & format! (
" Could not update live input target: {err} "
) ) ;
refresh_launcher_ui ( & widgets , & state . borrow ( ) , true ) ;
return ;
2026-04-14 04:02:39 -03:00
}
2026-04-14 20:05:26 -03:00
widgets . status_label . set_text ( & format! (
" Requested {} input control - pressing the swap key mirrors this live. " ,
routing_name ( next )
) ) ;
2026-04-14 04:02:39 -03:00
} else {
2026-04-14 20:05:26 -03:00
widgets . status_label . set_text ( & format! (
" Relay will start with {} input control. " ,
routing_name ( next )
) ) ;
2026-04-14 04:02:39 -03:00
}
2026-04-14 20:05:26 -03:00
state . borrow_mut ( ) . set_routing ( next ) ;
refresh_launcher_ui ( & widgets , & state . borrow ( ) , child_running ) ;
2026-04-14 04:02:39 -03:00
} ) ;
}
2026-04-13 23:11:35 -03:00
{
2026-04-14 13:09:25 -03:00
let child_proc = Rc ::clone ( & child_proc ) ;
2026-04-14 20:05:26 -03:00
let widgets = widgets . clone ( ) ;
2026-04-14 13:09:25 -03:00
let server_entry = server_entry . clone ( ) ;
2026-04-14 20:05:26 -03:00
let server_addr_fallback = Rc ::clone ( & server_addr ) ;
2026-04-14 13:09:25 -03:00
clipboard_button . connect_clicked ( move | _ | {
if child_proc . borrow ( ) . is_none ( ) {
2026-04-14 20:05:26 -03:00
widgets . status_label . set_text ( " Start relay before sending clipboard " ) ;
2026-04-14 13:09:25 -03:00
return ;
}
2026-04-14 20:05:26 -03:00
let server_addr =
selected_server_addr ( & server_entry , server_addr_fallback . as_ref ( ) ) ;
widgets . status_label . set_text ( " Sending clipboard to remote... " ) ;
2026-04-14 18:44:40 -03:00
let ( result_tx , result_rx ) = std ::sync ::mpsc ::channel ::< String > ( ) ;
std ::thread ::spawn ( move | | {
let message = match send_clipboard_to_remote ( & server_addr ) {
Ok ( mode ) = > mode ,
Err ( err ) = > format! ( " Clipboard send failed: {err} " ) ,
} ;
let _ = result_tx . send ( message ) ;
} ) ;
2026-04-14 20:05:26 -03:00
let status_label = widgets . status_label . clone ( ) ;
2026-04-14 18:44:40 -03:00
glib ::timeout_add_local ( Duration ::from_millis ( 100 ) , move | | {
match result_rx . try_recv ( ) {
Ok ( message ) = > {
status_label . set_text ( & message ) ;
glib ::ControlFlow ::Break
}
Err ( std ::sync ::mpsc ::TryRecvError ::Empty ) = > {
glib ::ControlFlow ::Continue
}
Err ( std ::sync ::mpsc ::TryRecvError ::Disconnected ) = > {
2026-04-14 20:05:26 -03:00
status_label
. set_text ( " Clipboard send failed: launcher worker exited " ) ;
2026-04-14 18:44:40 -03:00
glib ::ControlFlow ::Break
}
}
} ) ;
2026-04-13 23:11:35 -03:00
} ) ;
}
2026-04-14 20:05:26 -03:00
for monitor_id in 0 .. 2 {
let app = app . clone ( ) ;
let preview = preview . clone ( ) ;
let state = Rc ::clone ( & state ) ;
let child_proc = Rc ::clone ( & child_proc ) ;
let popouts = Rc ::clone ( & popouts ) ;
let widgets = widgets . clone ( ) ;
let action_button = widgets . display_panes [ monitor_id ] . action_button . clone ( ) ;
action_button . connect_clicked ( move | _ | {
let Some ( preview ) = preview . as_ref ( ) else {
widgets
. status_label
. set_text ( " Preview is unavailable for breakout windows " ) ;
return ;
} ;
let surface = state . borrow ( ) . display_surface ( monitor_id ) ;
match surface {
DisplaySurface ::Preview = > {
open_popout_window (
& app ,
preview ,
& state ,
& child_proc ,
& popouts ,
& widgets ,
monitor_id ,
) ;
widgets . status_label . set_text ( & format! (
" {} moved into its own window " ,
widgets . display_panes [ monitor_id ] . title
) ) ;
}
DisplaySurface ::Window = > {
dock_display_to_preview (
& state ,
& child_proc ,
& popouts ,
& widgets ,
monitor_id ,
) ;
widgets . status_label . set_text ( & format! (
" {} returned to the launcher preview " ,
widgets . display_panes [ monitor_id ] . title
) ) ;
}
}
} ) ;
}
{
let window = window . clone ( ) ;
let state = Rc ::clone ( & state ) ;
let child_proc = Rc ::clone ( & child_proc ) ;
let widgets = widgets . clone ( ) ;
let focus_signal_path = Rc ::clone ( & focus_signal_path ) ;
let input_state_path = Rc ::clone ( & input_state_path ) ;
let last_focus_marker =
Rc ::new ( RefCell ::new ( path_marker ( focus_signal_path . as_path ( ) ) ) ) ;
let last_state_marker =
Rc ::new ( RefCell ::new ( path_marker ( input_state_path . as_path ( ) ) ) ) ;
glib ::timeout_add_local ( Duration ::from_millis ( 180 ) , move | | {
let child_running = reap_exited_child ( & child_proc ) ;
if ! child_running & & state . borrow ( ) . remote_active {
let _ = state . borrow_mut ( ) . stop_remote ( ) ;
widgets . status_label . set_text ( " Relay ended " ) ;
}
let next_state_marker = path_marker ( input_state_path . as_path ( ) ) ;
let mut last_state = last_state_marker . borrow_mut ( ) ;
if next_state_marker > * last_state {
* last_state = next_state_marker ;
if let Some ( routing ) = read_input_routing_state ( input_state_path . as_path ( ) )
{
state . borrow_mut ( ) . set_routing ( routing ) ;
refresh_launcher_ui ( & widgets , & state . borrow ( ) , child_running ) ;
}
}
let next_focus_marker = path_marker ( focus_signal_path . as_path ( ) ) ;
let mut last_focus = last_focus_marker . borrow_mut ( ) ;
if next_focus_marker > * last_focus {
* last_focus = next_focus_marker ;
state . borrow_mut ( ) . set_routing ( InputRouting ::Local ) ;
refresh_launcher_ui ( & widgets , & state . borrow ( ) , child_running ) ;
widgets
. status_label
. set_text ( " Local control restored - launcher focused " ) ;
window . present ( ) ;
}
refresh_launcher_ui ( & widgets , & state . borrow ( ) , child_running ) ;
glib ::ControlFlow ::Continue
} ) ;
}
2026-04-14 14:38:03 -03:00
scroll . set_child ( Some ( & root ) ) ;
window . set_child ( Some ( & scroll ) ) ;
2026-04-13 23:11:35 -03:00
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 ( ( ) )
}
2026-04-14 20:05:26 -03:00
#[ cfg(not(coverage)) ]
fn build_section ( title : & str ) -> ( gtk ::Frame , gtk ::Box ) {
let frame = gtk ::Frame ::new ( Some ( title ) ) ;
let body = gtk ::Box ::new ( gtk ::Orientation ::Vertical , 10 ) ;
body . set_margin_start ( 12 ) ;
body . set_margin_end ( 12 ) ;
body . set_margin_top ( 12 ) ;
body . set_margin_bottom ( 12 ) ;
frame . set_child ( Some ( & body ) ) ;
( frame , body )
}
#[ cfg(not(coverage)) ]
fn attach_summary_row ( grid : & gtk ::Grid , row : i32 , label : & str , value : & str ) -> gtk ::Label {
let key = gtk ::Label ::new ( Some ( label ) ) ;
key . set_halign ( gtk ::Align ::Start ) ;
let value_label = gtk ::Label ::new ( Some ( value ) ) ;
value_label . set_halign ( gtk ::Align ::Start ) ;
value_label . set_selectable ( true ) ;
grid . attach ( & key , 0 , row , 1 , 1 ) ;
grid . attach ( & value_label , 1 , row , 1 , 1 ) ;
value_label
}
#[ cfg(not(coverage)) ]
fn build_display_pane ( title : & str ) -> DisplayPaneWidgets {
let root = gtk ::Box ::new ( gtk ::Orientation ::Vertical , 8 ) ;
root . set_hexpand ( true ) ;
root . set_vexpand ( true ) ;
let title_label = gtk ::Label ::new ( Some ( title ) ) ;
title_label . add_css_class ( " title-4 " ) ;
title_label . set_halign ( gtk ::Align ::Center ) ;
root . append ( & title_label ) ;
let picture = gtk ::Picture ::new ( ) ;
picture . set_hexpand ( true ) ;
picture . set_vexpand ( true ) ;
picture . set_can_shrink ( true ) ;
picture . set_size_request ( 460 , 258 ) ;
let preview_box = gtk ::Box ::new ( gtk ::Orientation ::Vertical , 6 ) ;
preview_box . append ( & picture ) ;
let placeholder = gtk ::Label ::new ( Some ( " This display is docked in the launcher preview. " ) ) ;
placeholder . set_wrap ( true ) ;
placeholder . set_justify ( gtk ::Justification ::Center ) ;
placeholder . set_halign ( gtk ::Align ::Center ) ;
placeholder . set_valign ( gtk ::Align ::Center ) ;
let placeholder_box = gtk ::Box ::new ( gtk ::Orientation ::Vertical , 6 ) ;
placeholder_box . set_hexpand ( true ) ;
placeholder_box . set_vexpand ( true ) ;
placeholder_box . set_size_request ( 460 , 258 ) ;
placeholder_box . append ( & placeholder ) ;
let stack = gtk ::Stack ::new ( ) ;
stack . set_hexpand ( true ) ;
stack . set_vexpand ( true ) ;
stack . add_named ( & preview_box , Some ( " preview " ) ) ;
stack . add_named ( & placeholder_box , Some ( " placeholder " ) ) ;
stack . set_visible_child_name ( " preview " ) ;
root . append ( & stack ) ;
let stream_status = gtk ::Label ::new ( Some ( " Waiting for stream... " ) ) ;
stream_status . set_halign ( gtk ::Align ::Start ) ;
root . append ( & stream_status ) ;
let action_button = gtk ::Button ::with_label ( " Break Out " ) ;
action_button . set_halign ( gtk ::Align ::Center ) ;
root . append ( & action_button ) ;
DisplayPaneWidgets {
root ,
stack ,
picture ,
stream_status ,
placeholder ,
action_button ,
preview_binding : None ,
title : title . to_string ( ) ,
}
}
#[ cfg(not(coverage)) ]
fn refresh_launcher_ui ( widgets : & LauncherWidgets , state : & LauncherState , child_running : bool ) {
widgets . summary . relay_value . set_text ( if child_running | | state . remote_active {
" Running "
} else {
" Stopped "
} ) ;
widgets
. summary
. routing_value
. set_text ( routing_name ( state . routing ) ) ;
widgets . summary . displays_value . set_text ( & format! (
" Display 1: {} | Display 2: {} " ,
state . display_surface ( 0 ) . label ( ) ,
state . display_surface ( 1 ) . label ( )
) ) ;
widgets
. summary
. shortcut_value
. set_text ( & selected_toggle_key_label ( & widgets . toggle_key_combo ) ) ;
widgets . start_button . set_sensitive ( ! child_running ) ;
widgets . stop_button . set_sensitive ( child_running ) ;
widgets . clipboard_button . set_sensitive ( child_running ) ;
widgets . input_toggle_button . set_label ( match state . routing {
InputRouting ::Remote = > " Switch To Local Inputs " ,
InputRouting ::Local = > " Switch To Remote Inputs " ,
} ) ;
for monitor_id in 0 .. 2 {
refresh_display_pane ( & widgets . display_panes [ monitor_id ] , state . display_surface ( monitor_id ) ) ;
}
}
#[ cfg(not(coverage)) ]
fn refresh_display_pane ( pane : & DisplayPaneWidgets , surface : DisplaySurface ) {
if let Some ( binding ) = pane . preview_binding . as_ref ( ) {
binding . set_enabled ( matches! ( surface , DisplaySurface ::Preview ) ) ;
}
pane . action_button . set_sensitive ( pane . preview_binding . is_some ( ) ) ;
match surface {
DisplaySurface ::Preview = > {
pane . stack . set_visible_child_name ( " preview " ) ;
pane . action_button . set_label ( " Break Out " ) ;
pane . placeholder
. set_text ( " This display is docked in the launcher preview. " ) ;
if pane . preview_binding . is_none ( ) {
pane . stream_status . set_text ( " Preview unavailable " ) ;
}
}
DisplaySurface ::Window = > {
pane . stack . set_visible_child_name ( " placeholder " ) ;
pane . action_button . set_label ( " Return To Preview " ) ;
pane . placeholder . set_text ( & format! (
" {} is open in its own window. \n Use \" Return To Preview \" to dock it back here. " ,
pane . title
) ) ;
pane . stream_status . set_text ( " Streaming in its own window " ) ;
}
}
}
#[ cfg(not(coverage)) ]
fn open_popout_window (
app : & gtk ::Application ,
preview : & LauncherPreview ,
state : & Rc < RefCell < LauncherState > > ,
child_proc : & Rc < RefCell < Option < Child > > > ,
popouts : & Rc < RefCell < [ Option < PopoutWindowHandle > ; 2 ] > > ,
widgets : & LauncherWidgets ,
monitor_id : usize ,
) {
let already_open = popouts . borrow ( ) [ monitor_id ] . is_some ( ) ;
if already_open {
return ;
}
if let Some ( binding ) = widgets . display_panes [ monitor_id ] . preview_binding . as_ref ( ) {
binding . set_enabled ( false ) ;
}
let window = gtk ::ApplicationWindow ::builder ( )
. application ( app )
. title ( & format! ( " Lesavka {} " , widgets . display_panes [ monitor_id ] . title ) )
. default_width ( 1280 )
. default_height ( 760 )
. build ( ) ;
window . maximize ( ) ;
let root = gtk ::Box ::new ( gtk ::Orientation ::Vertical , 8 ) ;
root . set_margin_start ( 10 ) ;
root . set_margin_end ( 10 ) ;
root . set_margin_top ( 10 ) ;
root . set_margin_bottom ( 10 ) ;
let title = gtk ::Label ::new ( Some ( & widgets . display_panes [ monitor_id ] . title ) ) ;
title . add_css_class ( " title-3 " ) ;
title . set_halign ( gtk ::Align ::Center ) ;
root . append ( & title ) ;
let picture = gtk ::Picture ::new ( ) ;
picture . set_hexpand ( true ) ;
picture . set_vexpand ( true ) ;
picture . set_can_shrink ( true ) ;
root . append ( & picture ) ;
let stream_status = gtk ::Label ::new ( Some ( " Waiting for stream... " ) ) ;
stream_status . set_halign ( gtk ::Align ::Start ) ;
root . append ( & stream_status ) ;
let binding = preview
. install_on_picture ( monitor_id , & picture , & stream_status )
. expect ( " preview binding for popout " ) ;
window . set_child ( Some ( & root ) ) ;
let state_handle = Rc ::clone ( state ) ;
let child_proc_handle = Rc ::clone ( child_proc ) ;
let popouts_handle = Rc ::clone ( popouts ) ;
let widgets_handle = widgets . clone ( ) ;
let close_binding = binding . clone ( ) ;
window . connect_close_request ( move | _ | {
let handle = {
let mut popouts = popouts_handle . borrow_mut ( ) ;
popouts [ monitor_id ] . take ( )
} ;
if let Some ( handle ) = handle {
handle . binding . close ( ) ;
if let Some ( preview_binding ) =
widgets_handle . display_panes [ monitor_id ] . preview_binding . as_ref ( )
{
preview_binding . set_enabled ( true ) ;
}
state_handle
. borrow_mut ( )
. set_display_surface ( monitor_id , DisplaySurface ::Preview ) ;
refresh_launcher_ui (
& widgets_handle ,
& state_handle . borrow ( ) ,
child_proc_handle . borrow ( ) . is_some ( ) ,
) ;
} else {
close_binding . close ( ) ;
}
glib ::Propagation ::Proceed
} ) ;
state
. borrow_mut ( )
. set_display_surface ( monitor_id , DisplaySurface ::Window ) ;
popouts . borrow_mut ( ) [ monitor_id ] = Some ( PopoutWindowHandle {
window : window . clone ( ) ,
binding ,
} ) ;
refresh_launcher_ui ( widgets , & state . borrow ( ) , child_proc . borrow ( ) . is_some ( ) ) ;
window . present ( ) ;
}
#[ cfg(not(coverage)) ]
fn dock_display_to_preview (
state : & Rc < RefCell < LauncherState > > ,
child_proc : & Rc < RefCell < Option < Child > > > ,
popouts : & Rc < RefCell < [ Option < PopoutWindowHandle > ; 2 ] > > ,
widgets : & LauncherWidgets ,
monitor_id : usize ,
) {
let handle = {
let mut popouts = popouts . borrow_mut ( ) ;
popouts [ monitor_id ] . take ( )
} ;
if let Some ( handle ) = handle {
handle . binding . close ( ) ;
handle . window . close ( ) ;
}
if let Some ( binding ) = widgets . display_panes [ monitor_id ] . preview_binding . as_ref ( ) {
binding . set_enabled ( true ) ;
}
state
. borrow_mut ( )
. set_display_surface ( monitor_id , DisplaySurface ::Preview ) ;
refresh_launcher_ui ( widgets , & state . borrow ( ) , child_proc . borrow ( ) . is_some ( ) ) ;
}
2026-04-13 23:11:35 -03:00
#[ 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 ( ) )
}
} )
}
2026-04-14 13:09:25 -03:00
#[ 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 ( ) )
}
2026-04-14 20:05:26 -03:00
#[ cfg(not(coverage)) ]
fn selected_toggle_key_label ( combo : & gtk ::ComboBoxText ) -> String {
combo . active_text ( )
. map ( | value | value . to_string ( ) )
. unwrap_or_else ( | | " Pause " . to_string ( ) )
}
2026-04-14 13:09:25 -03:00
#[ 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)) ]
2026-04-14 20:05:26 -03:00
fn input_control_path ( ) -> PathBuf {
std ::env ::var ( INPUT_CONTROL_ENV )
. map ( PathBuf ::from )
. unwrap_or_else ( | _ | PathBuf ::from ( DEFAULT_INPUT_CONTROL_PATH ) )
2026-04-14 13:09:25 -03:00
}
#[ cfg(not(coverage)) ]
2026-04-14 20:05:26 -03:00
fn input_state_path ( ) -> PathBuf {
std ::env ::var ( INPUT_STATE_ENV )
. map ( PathBuf ::from )
. unwrap_or_else ( | _ | PathBuf ::from ( DEFAULT_INPUT_STATE_PATH ) )
}
2026-04-14 18:44:40 -03:00
#[ cfg(not(coverage)) ]
2026-04-14 20:05:26 -03:00
fn write_input_routing_request ( path : & Path , routing : InputRouting ) -> Result < ( ) > {
std ::fs ::write ( path , format! ( " {} \n " , routing_name ( routing ) ) ) ? ;
Ok ( ( ) )
2026-04-14 13:09:25 -03:00
}
#[ cfg(not(coverage)) ]
2026-04-14 20:05:26 -03:00
fn read_input_routing_state ( path : & Path ) -> Option < InputRouting > {
let raw = std ::fs ::read_to_string ( path ) . ok ( ) ? ;
match raw . trim ( ) . to_ascii_lowercase ( ) . as_str ( ) {
" local " = > Some ( InputRouting ::Local ) ,
" remote " = > Some ( InputRouting ::Remote ) ,
_ = > None ,
2026-04-14 18:44:40 -03:00
}
2026-04-14 20:05:26 -03:00
}
#[ cfg(not(coverage)) ]
fn routing_name ( routing : InputRouting ) -> & 'static str {
match routing {
InputRouting ::Local = > " local " ,
InputRouting ::Remote = > " remote " ,
2026-04-14 13:09:25 -03:00
}
2026-04-14 18:44:40 -03:00
}
#[ cfg(not(coverage)) ]
2026-04-14 20:05:26 -03:00
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 ( )
2026-04-14 13:09:25 -03:00
}
2026-04-13 23:11:35 -03:00
#[ 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)) ]
2026-04-14 13:09:25 -03:00
fn spawn_client_process (
server_addr : & str ,
state : & LauncherState ,
input_toggle_key : & str ,
2026-04-14 20:05:26 -03:00
input_control_path : & Path ,
input_state_path : & Path ,
2026-04-14 13:09:25 -03:00
) -> Result < Child > {
2026-04-13 23:11:35 -03:00
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 ) ;
2026-04-14 13:09:25 -03:00
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 " ) ;
2026-04-14 18:44:40 -03:00
command . env ( LAUNCHER_FOCUS_SIGNAL_ENV , launcher_focus_signal_path ( ) ) ;
2026-04-14 20:05:26 -03:00
command . env ( INPUT_CONTROL_ENV , input_control_path ) ;
command . env ( INPUT_STATE_ENV , input_state_path ) ;
command . env ( " LESAVKA_DISABLE_VIDEO_RENDER " , " 1 " ) ;
command . env ( " LESAVKA_CLIPBOARD_PASTE " , " 0 " ) ;
2026-04-13 23:11:35 -03:00
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)) ]
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)) ]
2026-04-14 20:05:26 -03:00
fn reap_exited_child ( child_proc : & Rc < RefCell < Option < Child > > > ) -> bool {
let mut slot = child_proc . borrow_mut ( ) ;
match slot . as_mut ( ) {
Some ( child ) = > match child . try_wait ( ) {
Ok ( Some ( _ ) ) = > {
* slot = None ;
false
}
Ok ( None ) | Err ( _ ) = > true ,
} ,
None = > false ,
2026-04-14 04:02:39 -03:00
}
}
#[ cfg(not(coverage)) ]
fn next_input_routing ( routing : InputRouting ) -> InputRouting {
match routing {
InputRouting ::Remote = > InputRouting ::Local ,
InputRouting ::Local = > InputRouting ::Remote ,
}
}
2026-04-13 23:11:35 -03:00
#[ 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 ( ) ) ;
}
}