lesavka/client/src/launcher/ui/utility_button_bindings.rs

591 lines
24 KiB
Rust
Raw Normal View History

{
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<PathBuf>,
timer: Option<glib::SourceId>,
frame_dir: Option<PathBuf>,
output_path: Option<PathBuf>,
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<PathBuf, String> {
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: &gtk::Picture) -> Result<gtk::gdk::Texture, String> {
let paintable = picture
.paintable()
.ok_or_else(|| "no live frame is available yet".to_string())?;
paintable
.downcast::<gtk::gdk::Texture>()
.map_err(|_| "the current frame is not directly exportable".to_string())
}
fn save_texture_png(texture: &gtk::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: &gtk::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<PathBuf, String> {
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::<&gtk::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.");
});
}
}