724 lines
29 KiB
Rust
724 lines
29 KiB
Rust
{
|
|
const DEFAULT_EYE_RECORD_FPS: u32 = 30;
|
|
|
|
#[derive(Default)]
|
|
struct EyeRecordState {
|
|
save_dir_override: Option<PathBuf>,
|
|
timer: Option<glib::SourceId>,
|
|
frame_dir: Option<PathBuf>,
|
|
frame_writer_tx: Option<std::sync::mpsc::Sender<RecordFrameTask>>,
|
|
finalize_rx: Option<std::sync::mpsc::Receiver<Result<PathBuf, String>>>,
|
|
next_frame_index: u32,
|
|
}
|
|
|
|
enum RecordFrameTask {
|
|
Frame {
|
|
texture: gtk::gdk::Texture,
|
|
frame_path: PathBuf,
|
|
},
|
|
Finish,
|
|
}
|
|
|
|
fn eye_slug(title: &str) -> &'static str {
|
|
if title.to_ascii_lowercase().contains("left") {
|
|
"left-eye"
|
|
} else {
|
|
"right-eye"
|
|
}
|
|
}
|
|
|
|
fn timestamp_slug() -> String {
|
|
if let Ok(now) = glib::DateTime::now_local()
|
|
&& let Ok(stamp) = now.format("%Y%m%d-%H%M%S")
|
|
{
|
|
return stamp.to_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: >k::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: >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 recording_interval_ms(record_fps: u32) -> u64 {
|
|
let fps = record_fps.max(1);
|
|
(1000_u64 / fps as u64).max(1)
|
|
}
|
|
|
|
fn best_effort_recording_profile(
|
|
state: &LauncherState,
|
|
preview: Option<&LauncherPreview>,
|
|
monitor_id: usize,
|
|
) -> (u32, u32) {
|
|
let choice = state
|
|
.display_capture_size_choice(monitor_id)
|
|
.unwrap_or_else(|| state.capture_size_choice(monitor_id));
|
|
let mut fps = if choice.fps == 0 {
|
|
DEFAULT_EYE_RECORD_FPS
|
|
} else {
|
|
choice.fps.max(1)
|
|
};
|
|
if let Some(snapshot) =
|
|
preview.and_then(|feed| feed.snapshot_metrics(monitor_id, PreviewSurface::Inline))
|
|
&& snapshot.server_fps.is_finite()
|
|
&& snapshot.server_fps >= 1.0
|
|
{
|
|
fps = snapshot.server_fps.round().clamp(1.0, 120.0) as u32;
|
|
}
|
|
let bitrate_kbit = choice.max_bitrate_kbit.max(800);
|
|
(fps, bitrate_kbit)
|
|
}
|
|
|
|
fn queue_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 frame_writer_tx = state
|
|
.frame_writer_tx
|
|
.as_ref()
|
|
.ok_or_else(|| "recording worker 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));
|
|
frame_writer_tx
|
|
.send(RecordFrameTask::Frame {
|
|
texture,
|
|
frame_path,
|
|
})
|
|
.map_err(|_| "recording worker stopped unexpectedly".to_string())?;
|
|
state.next_frame_index = state.next_frame_index.saturating_add(1);
|
|
Ok(())
|
|
}
|
|
|
|
fn encode_recording(
|
|
frame_dir: &PathBuf,
|
|
output_path: &PathBuf,
|
|
encode_fps: u32,
|
|
encode_bitrate_kbit: u32,
|
|
) -> Result<(), String> {
|
|
let frame_pattern = frame_dir.join("frame-%06d.png");
|
|
let bitrate_arg = format!("{}k", encode_bitrate_kbit.max(800));
|
|
let encode = Command::new("ffmpeg")
|
|
.args([
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-framerate",
|
|
&encode_fps.max(1).to_string(),
|
|
"-i",
|
|
&frame_pattern.to_string_lossy(),
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-r",
|
|
&encode_fps.max(1).to_string(),
|
|
"-b:v",
|
|
&bitrate_arg,
|
|
&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()
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_recording_worker(
|
|
frame_rx: std::sync::mpsc::Receiver<RecordFrameTask>,
|
|
frame_dir: PathBuf,
|
|
output_path: PathBuf,
|
|
encode_fps: u32,
|
|
encode_bitrate_kbit: u32,
|
|
) -> Result<PathBuf, String> {
|
|
let mut captured_frames = 0_u32;
|
|
loop {
|
|
match frame_rx.recv() {
|
|
Ok(RecordFrameTask::Frame {
|
|
texture,
|
|
frame_path,
|
|
}) => {
|
|
save_texture_png(&texture, &frame_path)?;
|
|
captured_frames = captured_frames.saturating_add(1);
|
|
}
|
|
Ok(RecordFrameTask::Finish) | Err(_) => break,
|
|
}
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
encode_recording(&frame_dir, &output_path, encode_fps, encode_bitrate_kbit)?;
|
|
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 state = Rc::clone(&state);
|
|
let preview = preview.clone();
|
|
let record_button = pane.record_button.clone();
|
|
record_button.connect_clicked(move |button| {
|
|
if save_state.borrow().timer.is_some() {
|
|
let finalize_rx = {
|
|
let mut state = save_state.borrow_mut();
|
|
if let Some(timer) = state.timer.take() {
|
|
timer.remove();
|
|
}
|
|
if let Some(frame_writer_tx) = state.frame_writer_tx.take() {
|
|
let _ = frame_writer_tx.send(RecordFrameTask::Finish);
|
|
}
|
|
state.next_frame_index = 0;
|
|
state.frame_dir = None;
|
|
state.finalize_rx.take()
|
|
};
|
|
let Some(finalize_rx) = finalize_rx else {
|
|
button.set_label("Record");
|
|
widgets.status_label.set_text(&format!(
|
|
"{} recording stop failed: recording worker state was missing.",
|
|
pane.title
|
|
));
|
|
return;
|
|
};
|
|
|
|
button.set_sensitive(false);
|
|
button.set_label("Finishing...");
|
|
let button = button.clone();
|
|
let widgets = widgets.clone();
|
|
let pane_title = pane.title.clone();
|
|
glib::timeout_add_local(Duration::from_millis(100), move || match finalize_rx
|
|
.try_recv()
|
|
{
|
|
Ok(Ok(output)) => {
|
|
button.set_sensitive(true);
|
|
button.set_label("Record");
|
|
widgets.status_label.set_text(&format!(
|
|
"{} recording saved to {}.",
|
|
pane_title,
|
|
output.display()
|
|
));
|
|
glib::ControlFlow::Break
|
|
}
|
|
Ok(Err(err)) => {
|
|
button.set_sensitive(true);
|
|
button.set_label("Record");
|
|
widgets.status_label.set_text(&format!(
|
|
"{} recording stop failed: {err}",
|
|
pane_title,
|
|
));
|
|
glib::ControlFlow::Break
|
|
}
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
|
button.set_sensitive(true);
|
|
button.set_label("Record");
|
|
widgets.status_label.set_text(&format!(
|
|
"{} recording stop failed: recording worker disconnected.",
|
|
pane_title
|
|
));
|
|
glib::ControlFlow::Break
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
let (record_fps, record_bitrate_kbit) = {
|
|
let state = state.borrow();
|
|
best_effort_recording_profile(&state, preview.as_deref(), monitor_id)
|
|
};
|
|
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 (frame_tx, frame_rx) = std::sync::mpsc::channel::<RecordFrameTask>();
|
|
let (result_tx, result_rx) = std::sync::mpsc::channel::<Result<PathBuf, String>>();
|
|
let frame_dir_worker = frame_dir.clone();
|
|
let output_path_worker = output_path.clone();
|
|
std::thread::spawn(move || {
|
|
let result = run_recording_worker(
|
|
frame_rx,
|
|
frame_dir_worker,
|
|
output_path_worker,
|
|
record_fps,
|
|
record_bitrate_kbit,
|
|
);
|
|
let _ = result_tx.send(result);
|
|
});
|
|
|
|
{
|
|
let mut state = save_state.borrow_mut();
|
|
state.frame_dir = Some(frame_dir);
|
|
state.frame_writer_tx = Some(frame_tx);
|
|
state.finalize_rx = Some(result_rx);
|
|
state.next_frame_index = 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(recording_interval_ms(record_fps)),
|
|
move || {
|
|
let mut state = save_state_for_tick.borrow_mut();
|
|
if state.frame_dir.is_none() {
|
|
return glib::ControlFlow::Break;
|
|
}
|
|
if let Err(err) = queue_record_frame(&mut state, &pane_for_tick.picture) {
|
|
if let Some(frame_writer_tx) = state.frame_writer_tx.take() {
|
|
let _ = frame_writer_tx.send(RecordFrameTask::Finish);
|
|
}
|
|
widgets_for_tick.status_label.set_text(&format!(
|
|
"{} recording frame skipped: {err}",
|
|
pane_for_tick.title
|
|
));
|
|
return glib::ControlFlow::Break;
|
|
}
|
|
glib::ControlFlow::Continue
|
|
},
|
|
);
|
|
save_state.borrow_mut().timer = Some(timer);
|
|
button.set_sensitive(true);
|
|
button.set_label("Stop");
|
|
widgets.status_label.set_text(&format!(
|
|
"Recording {} at {} fps (~{} kbit)... press Stop to finish.",
|
|
pane.title, record_fps, record_bitrate_kbit
|
|
));
|
|
});
|
|
}
|
|
}
|
|
|
|
{
|
|
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(
|
|
"Recover USB 1/3: sending gadget reset request to 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(
|
|
"Recover USB 2/3: relay acknowledged reset. Recover USB 3/3: waiting for USB/UAC/UVC chips to settle.",
|
|
);
|
|
glib::ControlFlow::Break
|
|
}
|
|
Ok(Err(err)) => {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Recover USB 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(
|
|
"Recover USB failed: relay stopped responding before completion.",
|
|
);
|
|
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("Recover UAC 1/3: sending gadget reset request to 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(
|
|
"Recover UAC 2/3: relay acknowledged reset. Recover UAC 3/3: waiting for UAC chip to settle.",
|
|
);
|
|
glib::ControlFlow::Break
|
|
}
|
|
Ok(Err(err)) => {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Recover UAC 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(
|
|
"Recover UAC failed: relay stopped responding before completion.",
|
|
);
|
|
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("Recover UVC 1/3: sending gadget reset request to 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(
|
|
"Recover UVC 2/3: relay acknowledged reset. Recover UVC 3/3: waiting for UVC chip to settle.",
|
|
);
|
|
glib::ControlFlow::Break
|
|
}
|
|
Ok(Err(err)) => {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Recover UVC 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(
|
|
"Recover UVC failed: relay stopped responding before completion.",
|
|
);
|
|
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.");
|
|
});
|
|
}
|
|
}
|