{ const EYE_RECORD_FPS: u32 = 20; const EYE_RECORD_FRAME_INTERVAL_MS: u64 = 1000 / EYE_RECORD_FPS as u64; #[derive(Default)] struct EyeRecordState { save_dir_override: Option, timer: Option, frame_dir: Option, output_path: Option, next_frame_index: u32, captured_frames: u32, } fn eye_slug(title: &str) -> &'static str { if title.to_ascii_lowercase().contains("left") { "left-eye" } else { "right-eye" } } fn timestamp_slug() -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); format!("{}-{:03}", now.as_secs(), now.subsec_millis()) } fn expand_home_token(raw: &str) -> PathBuf { if raw.contains("$HOME") { if let Some(home) = std::env::var_os("HOME") { return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy())); } } if let Some(rest) = raw.strip_prefix("~/") && let Some(home) = std::env::var_os("HOME") { return PathBuf::from(home).join(rest); } PathBuf::from(raw) } fn default_eye_capture_root() -> PathBuf { if let Some(raw) = std::env::var_os("XDG_PICTURES_DIR") { let path = expand_home_token(&raw.to_string_lossy()); if !path.as_os_str().is_empty() { return path.join("Lesavka"); } } if let Some(home) = std::env::var_os("HOME") { return PathBuf::from(home).join("Pictures").join("Lesavka"); } if let Some(profile) = std::env::var_os("USERPROFILE") { return PathBuf::from(profile).join("Pictures").join("Lesavka"); } std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join("Lesavka") } fn ensure_eye_capture_root(override_dir: Option<&PathBuf>) -> Result { let root = override_dir .cloned() .unwrap_or_else(default_eye_capture_root); std::fs::create_dir_all(&root) .map_err(|err| format!("could not create {}: {err}", root.display()))?; Ok(root) } fn unique_capture_path(root: &PathBuf, stem: &str, ext: &str) -> PathBuf { let mut candidate = root.join(format!("{stem}.{ext}")); if !candidate.exists() { return candidate; } for idx in 1..1000 { candidate = root.join(format!("{stem}-{idx}.{ext}")); if !candidate.exists() { break; } } candidate } fn current_eye_texture(picture: >k::Picture) -> Result { let paintable = picture .paintable() .ok_or_else(|| "no live frame is available yet".to_string())?; paintable .downcast::() .map_err(|_| "the current frame is not directly exportable".to_string()) } fn save_texture_png(texture: >k::gdk::Texture, output_path: &PathBuf) -> Result<(), String> { texture .save_to_png(output_path) .map_err(|err| format!("could not write {}: {err}", output_path.display())) } fn write_record_frame(state: &mut EyeRecordState, picture: >k::Picture) -> Result<(), String> { let frame_dir = state .frame_dir .as_ref() .ok_or_else(|| "recording session is not initialized".to_string())? .clone(); let texture = current_eye_texture(picture)?; let frame_path = frame_dir.join(format!("frame-{:06}.png", state.next_frame_index)); save_texture_png(&texture, &frame_path)?; state.next_frame_index = state.next_frame_index.saturating_add(1); state.captured_frames = state.captured_frames.saturating_add(1); Ok(()) } fn finalize_recording(state: &mut EyeRecordState) -> Result { let frame_dir = state .frame_dir .take() .ok_or_else(|| "recording frames were not initialized".to_string())?; let output_path = state .output_path .take() .ok_or_else(|| "recording output path was not initialized".to_string())?; let captured_frames = state.captured_frames; state.captured_frames = 0; state.next_frame_index = 0; if captured_frames < 2 { let _ = std::fs::remove_dir_all(&frame_dir); return Err("need at least two captured frames to build a recording".to_string()); } let frame_pattern = frame_dir.join("frame-%06d.png"); let encode = Command::new("ffmpeg") .args([ "-hide_banner", "-loglevel", "error", "-y", "-framerate", &EYE_RECORD_FPS.to_string(), "-i", &frame_pattern.to_string_lossy(), "-c:v", "libx264", "-pix_fmt", "yuv420p", &output_path.to_string_lossy(), ]) .status() .map_err(|err| format!("ffmpeg is unavailable: {err}"))?; if !encode.success() { return Err(format!( "ffmpeg failed while encoding {}; frame data is still in {}", output_path.display(), frame_dir.display() )); } let _ = std::fs::remove_dir_all(&frame_dir); Ok(output_path) } { let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); let server_entry = server_entry.clone(); let server_addr_fallback = Rc::clone(&server_addr); let clipboard_tx = clipboard_tx.clone(); widgets.clipboard_button.connect_clicked(move |_| { if child_proc.borrow().is_none() { widgets .status_label .set_text("Start the relay before sending clipboard text."); return; } let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); let Some(display) = gtk::gdk::Display::default() else { widgets .status_label .set_text("No desktop clipboard is available in this session."); return; }; widgets .status_label .set_text("Reading the local clipboard and preparing remote paste..."); let clipboard = display.clipboard(); let clipboard_tx = clipboard_tx.clone(); clipboard.read_text_async(None::<>k::gio::Cancellable>, move |result| match result { Ok(Some(text)) => { let text = text.trim_end_matches(['\r', '\n']).to_string(); if text.is_empty() { let _ = clipboard_tx .send(ClipboardMessage::Finished(Err("clipboard is empty".to_string()))); return; } let clipboard_tx = clipboard_tx.clone(); std::thread::spawn(move || { let result = send_clipboard_text_to_remote(&server_addr, &text) .map_err(|err| err.to_string()); let _ = clipboard_tx.send(ClipboardMessage::Finished(result)); }); } Ok(None) => { let _ = clipboard_tx .send(ClipboardMessage::Finished(Err("clipboard is empty".to_string()))); } Err(err) => { let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(format!( "clipboard read failed: {err}" )))); } }); }); } for monitor_id in 0..2 { let pane = widgets.display_panes[monitor_id].clone(); let widgets_for_ui = widgets.clone(); let save_state = Rc::new(RefCell::new(EyeRecordState::default())); { let pane = pane.clone(); let widgets = widgets_for_ui.clone(); let save_state = Rc::clone(&save_state); let window_for_save = window.clone(); pane.save_button.connect_clicked(move |_| { let chooser = gtk::FileChooserNative::new( Some("Choose Eye Capture Folder"), Some(&window_for_save), gtk::FileChooserAction::SelectFolder, Some("Select"), Some("Cancel"), ); chooser.set_modal(true); let save_state = Rc::clone(&save_state); let widgets = widgets.clone(); let eye_name = pane.title.clone(); chooser.connect_response(move |dialog, response| { if response == gtk::ResponseType::Accept { if let Some(folder) = dialog.file().and_then(|file| file.path()) { save_state.borrow_mut().save_dir_override = Some(folder.clone()); widgets.status_label.set_text(&format!( "{} saves now go to {}.", eye_name, folder.display() )); } else { widgets.status_label.set_text( "Capture folder selection did not return a filesystem path.", ); } } dialog.destroy(); }); chooser.show(); }); } { let pane = pane.clone(); let widgets = widgets_for_ui.clone(); let save_state = Rc::clone(&save_state); pane.clip_button.connect_clicked(move |_| { let root = { let borrowed = save_state.borrow(); match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) { Ok(path) => path, Err(err) => { widgets .status_label .set_text(&format!("Could not prepare capture folder: {err}")); return; } } }; let stem = format!("{}-clip-{}", eye_slug(&pane.title), timestamp_slug()); let clip_path = unique_capture_path(&root, &stem, "png"); match current_eye_texture(&pane.picture) .and_then(|texture| save_texture_png(&texture, &clip_path)) { Ok(()) => { widgets.status_label.set_text(&format!( "{} clip saved to {}.", pane.title, clip_path.display() )); } Err(err) => { widgets .status_label .set_text(&format!("{} clip failed: {err}", pane.title)); } } }); } { let pane = pane.clone(); let widgets = widgets_for_ui.clone(); let save_state = Rc::clone(&save_state); let record_button = pane.record_button.clone(); record_button.connect_clicked(move |button| { if save_state.borrow().timer.is_some() { let mut state = save_state.borrow_mut(); if let Some(timer) = state.timer.take() { timer.remove(); } drop(state); let mut state = save_state.borrow_mut(); match finalize_recording(&mut state) { Ok(output) => { button.set_label("Record"); widgets.status_label.set_text(&format!( "{} recording saved to {}.", pane.title, output.display() )); } Err(err) => { button.set_label("Record"); widgets.status_label.set_text(&format!( "{} recording stop failed: {err}", pane.title, )); } } return; } let root = { let borrowed = save_state.borrow(); match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) { Ok(path) => path, Err(err) => { widgets .status_label .set_text(&format!("Could not prepare capture folder: {err}")); return; } } }; let recording_stem = format!("{}-record-{}", eye_slug(&pane.title), timestamp_slug()); let output_path = unique_capture_path(&root, &recording_stem, "mp4"); let frame_dir = root.join(format!("{}.frames", recording_stem)); if let Err(err) = std::fs::create_dir_all(&frame_dir) { widgets.status_label.set_text(&format!( "{} record failed creating frame cache {}: {err}", pane.title, frame_dir.display() )); return; } { let mut state = save_state.borrow_mut(); state.frame_dir = Some(frame_dir); state.output_path = Some(output_path.clone()); state.next_frame_index = 0; state.captured_frames = 0; } let pane_for_tick = pane.clone(); let widgets_for_tick = widgets.clone(); let save_state_for_tick = Rc::clone(&save_state); let timer = glib::timeout_add_local( Duration::from_millis(EYE_RECORD_FRAME_INTERVAL_MS), move || { let mut state = save_state_for_tick.borrow_mut(); if state.frame_dir.is_none() { return glib::ControlFlow::Break; } if let Err(err) = write_record_frame(&mut state, &pane_for_tick.picture) { widgets_for_tick.status_label.set_text(&format!( "{} recording frame skipped: {err}", pane_for_tick.title )); } glib::ControlFlow::Continue }, ); save_state.borrow_mut().timer = Some(timer); button.set_label("Stop"); widgets.status_label.set_text(&format!( "Recording {}... press Stop to finish.", pane.title )); }); } } { let widgets = widgets.clone(); let server_entry = server_entry.clone(); let server_addr_fallback = Rc::clone(&server_addr); let widgets_for_click = widgets.clone(); widgets.usb_recover_button.connect_clicked(move |_| { let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets_for_click.status_label.set_text( "Requesting a forced USB gadget re-enumeration on the relay host...", ); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); let _ = tx.send(result); }); let widgets = widgets_for_click.clone(); glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() { Ok(Ok(())) => { widgets.status_label.set_text( "USB gadget recovery requested. Give the host a few seconds to re-enumerate keyboard, mouse, webcam, and audio.", ); glib::ControlFlow::Break } Ok(Err(err)) => { widgets .status_label .set_text(&format!("USB gadget recovery failed: {err}")); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Disconnected) => { widgets.status_label.set_text( "USB gadget recovery ended unexpectedly before the relay answered.", ); glib::ControlFlow::Break } }); }); } { let widgets = widgets.clone(); let server_entry = server_entry.clone(); let server_addr_fallback = Rc::clone(&server_addr); let widgets_for_click = widgets.clone(); widgets.uac_recover_button.connect_clicked(move |_| { let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets_for_click .status_label .set_text("Requesting UAC recovery (USB gadget rebuild) on the relay host..."); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); let _ = tx.send(result); }); let widgets = widgets_for_click.clone(); glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() { Ok(Ok(())) => { widgets.status_label.set_text( "UAC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate audio.", ); glib::ControlFlow::Break } Ok(Err(err)) => { widgets .status_label .set_text(&format!("UAC recovery failed: {err}")); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Disconnected) => { widgets.status_label.set_text( "UAC recovery ended unexpectedly before the relay answered.", ); glib::ControlFlow::Break } }); }); } { let widgets = widgets.clone(); let server_entry = server_entry.clone(); let server_addr_fallback = Rc::clone(&server_addr); let widgets_for_click = widgets.clone(); widgets.uvc_recover_button.connect_clicked(move |_| { let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets_for_click .status_label .set_text("Requesting UVC recovery (USB gadget rebuild) on the relay host..."); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); let _ = tx.send(result); }); let widgets = widgets_for_click.clone(); glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() { Ok(Ok(())) => { widgets.status_label.set_text( "UVC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate webcam video.", ); glib::ControlFlow::Break } Ok(Err(err)) => { widgets .status_label .set_text(&format!("UVC recovery failed: {err}")); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Disconnected) => { widgets.status_label.set_text( "UVC recovery ended unexpectedly before the relay answered.", ); glib::ControlFlow::Break } }); }); } { let widgets = widgets.clone(); widgets.diagnostics_copy_button.connect_clicked(move |_| { if let Err(err) = copy_plain_text(&widgets.diagnostics_rendered_text.borrow()) { widgets .status_label .set_text(&format!("Could not copy the diagnostics report: {err}")); } else { widgets .status_label .set_text("Diagnostics report copied to the local clipboard."); } }); } { let app = app.clone(); let widgets = widgets.clone(); let diagnostics_popout = Rc::clone(&diagnostics_popout); widgets.diagnostics_popout_button.connect_clicked(move |_| { open_diagnostics_popout( &app, &diagnostics_popout, &widgets.diagnostics_popout_label, &widgets.diagnostics_popout_scroll, &widgets.diagnostics_rendered_text, ); widgets .status_label .set_text("Diagnostics report moved into its own window."); }); } { let widgets = widgets.clone(); widgets.console_level_combo.connect_changed(move |combo| { let level = combo .active_id() .as_deref() .and_then(ConsoleLogLevel::from_id) .unwrap_or_default(); *widgets.session_log_level.borrow_mut() = level; widgets.status_label.set_text(&format!( "Console now shows {} relay logs and higher.", level.label() )); }); } { let widgets = widgets.clone(); widgets.console_copy_button.connect_clicked(move |_| { if let Err(err) = copy_session_log(&widgets.session_log_buffer) { widgets .status_label .set_text(&format!("Could not copy the session log: {err}")); } else { widgets .status_label .set_text("Session log copied to the local clipboard."); } }); } { let app = app.clone(); let widgets = widgets.clone(); let log_popout = Rc::clone(&log_popout); widgets.console_popout_button.connect_clicked(move |_| { open_session_log_popout(&app, &log_popout, &widgets.session_log_buffer); widgets .status_label .set_text("Session log moved into its own window."); }); } }