From ab1c5882d13fb92022f4d157c80560c87e9a2ca6 Mon Sep 17 00:00:00 2001 From: FallenSigh Date: Fri, 12 Jun 2026 20:58:44 +0800 Subject: [PATCH] Add log-to-file feature with background writer Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/xserial-gui/src/app.rs | 85 +++++++++++++++++++++- crates/xserial-gui/src/app_state.rs | 28 +++++++ crates/xserial-gui/src/logging.rs | 109 ++++++++++++++++++++++++++++ crates/xserial-gui/src/main.rs | 1 + 4 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 crates/xserial-gui/src/logging.rs diff --git a/crates/xserial-gui/src/app.rs b/crates/xserial-gui/src/app.rs index 4b887d1..7cc30c1 100644 --- a/crates/xserial-gui/src/app.rs +++ b/crates/xserial-gui/src/app.rs @@ -1,8 +1,10 @@ -use crate::app_state::{self, PersistedGuiState}; +use crate::app_state::{self, PersistedGuiState, SessionLogConfig}; +use std::path::PathBuf; use std::sync::mpsc; use std::time::{Duration, Instant}; use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer}; +use crate::logging::{self, LogWriter}; use crate::panels::{config, console, hex_view, plot_view, sidebar}; use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot}; use crate::shortcuts::{self, default_bindings}; @@ -156,6 +158,9 @@ pub struct SessionTab { pub line_ending: LineEnding, pub send_status: Option, pub search: SearchState, + pub log_enabled: bool, + pub log_path: String, + pub log_writer: Option, } pub struct XserialApp { @@ -375,8 +380,17 @@ impl XserialApp { } fn restore_saved_sessions(&mut self, saved_state: PersistedGuiState) { - for session_config in saved_state.sessions { + for (i, session_config) in saved_state.sessions.into_iter().enumerate() { self.add_session_tab(session_config); + if let Some(tab) = self.tabs.last_mut() { + if let Some(log_cfg) = saved_state.sessions_log.get(i) { + tab.log_enabled = log_cfg.enabled; + tab.log_path = log_cfg.file_path.clone(); + if log_cfg.enabled && !log_cfg.file_path.is_empty() { + tab.log_writer = LogWriter::open(&log_cfg.file_path).ok(); + } + } + } } if !self.tabs.is_empty() { self.active = saved_state.active.min(self.tabs.len() - 1); @@ -408,6 +422,9 @@ impl XserialApp { line_ending: LineEnding::None, send_status: None, search: SearchState::default(), + log_enabled: false, + log_path: String::new(), + log_writer: None, }); } @@ -418,6 +435,14 @@ impl XserialApp { .iter() .map(|tab| tab.session_config.clone()) .collect(), + sessions_log: self + .tabs + .iter() + .map(|tab| SessionLogConfig { + enabled: tab.log_enabled, + file_path: tab.log_path.clone(), + }) + .collect(), active: if self.tabs.is_empty() { 0 } else { @@ -496,6 +521,17 @@ impl XserialApp { SessionEvent::Data(id, entry) => { stats.drained_data_events += 1; if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) { + if let Some(ref writer) = tab.log_writer { + if let Some(line) = logging::format_data_log( + &entry, + "IN", + self.display.show_timestamp, + self.display.show_direction, + self.display.show_pipeline, + ) { + writer.write_line(&line); + } + } match &entry.data { DecodedData::Text(_) => { stats.drained_text_events += 1; @@ -1259,6 +1295,31 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess }); ui.add_space(6.0); + let log_toggled = ui + .checkbox(&mut tab.log_enabled, "Log to file") + .changed(); + if log_toggled { + if tab.log_enabled { + tab.log_path = default_log_path(tab.id) + .to_string_lossy() + .to_string(); + tab.log_writer = LogWriter::open(&tab.log_path).ok(); + } else { + tab.log_writer = None; + } + } + if tab.log_enabled { + ui.horizontal(|ui| { + let changed = ui + .text_edit_singleline(&mut tab.log_path) + .lost_focus(); + if changed && !tab.log_path.is_empty() { + tab.log_writer = LogWriter::open(&tab.log_path).ok(); + } + }); + } + ui.add_space(3.0); + let hint = match tab.send_mode { SendMode::Text => "Enter text to send", SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F", @@ -1304,7 +1365,10 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess LineEnding::CR => text.push('\r'), LineEnding::CRLF => text.push_str("\r\n"), } - tab.console.push_outbound(text); + tab.console.push_outbound(text.clone()); + if let Some(ref writer) = tab.log_writer { + writer.write_line(&logging::format_sent_log(&text)); + } } } SendMode::Hex => { @@ -1314,7 +1378,10 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess .collect::>() .join(" "); if !hex.is_empty() { - tab.hex.push_outbound(hex); + tab.hex.push_outbound(hex.clone()); + if let Some(ref writer) = tab.log_writer { + writer.write_line(&logging::format_sent_log(&hex)); + } } } } @@ -1336,6 +1403,16 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess } } +fn default_log_path(session_id: u64) -> PathBuf { + use std::time::{SystemTime, UNIX_EPOCH}; + let dir = app_state::config_dir().join("logs"); + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + dir.join(format!("session_{session_id}_{ts}.log")) +} + fn build_payload(tab: &SessionTab) -> Result>, String> { let trimmed = tab.send_input.trim(); if trimmed.is_empty() { diff --git a/crates/xserial-gui/src/app_state.rs b/crates/xserial-gui/src/app_state.rs index eb6e188..b1a87eb 100644 --- a/crates/xserial-gui/src/app_state.rs +++ b/crates/xserial-gui/src/app_state.rs @@ -9,11 +9,30 @@ use xserial_client::config::SessionConfig; const GUI_STATE_FILE_NAME: &str = "gui-state.json"; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionLogConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub file_path: String, +} + +impl Default for SessionLogConfig { + fn default() -> Self { + Self { + enabled: false, + file_path: String::new(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PersistedGuiState { #[serde(default)] pub sessions: Vec, #[serde(default)] + pub sessions_log: Vec, + #[serde(default)] pub active: usize, #[serde(default = "default_true")] pub show_timestamp: bool, @@ -44,6 +63,14 @@ fn gui_state_path() -> PathBuf { gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok()) } +/// Returns the xserial configuration directory (the parent of gui-state.json). +pub fn config_dir() -> PathBuf { + gui_state_path() + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) +} + fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option) -> PathBuf { match os { "windows" => { @@ -137,6 +164,7 @@ mod tests { }, ..SessionConfig::default() }], + sessions_log: vec![SessionLogConfig::default()], active: 0, show_timestamp: false, show_direction: true, diff --git a/crates/xserial-gui/src/logging.rs b/crates/xserial-gui/src/logging.rs new file mode 100644 index 0000000..33f23d3 --- /dev/null +++ b/crates/xserial-gui/src/logging.rs @@ -0,0 +1,109 @@ +use std::fs::{self, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::PathBuf; +use std::sync::mpsc; +use std::thread; + +use std::time::{SystemTime, UNIX_EPOCH}; + +use xserial_client::event::DecodedEntry; +use xserial_core::protocol::DecodedData; + +/// Manages background file logging via a dedicated writer thread. +/// +/// Lines are sent through an mpsc channel to a background thread that +/// appends to the log file using buffered I/O, keeping the GUI thread +/// non-blocking. +pub struct LogWriter { + sender: mpsc::Sender, +} + +impl LogWriter { + /// Spawns a background thread that opens `path` in append mode and + /// writes every line it receives through the returned `LogWriter`. + pub fn open(path: impl Into) -> std::io::Result { + let path: PathBuf = path.into(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let file = OpenOptions::new().create(true).append(true).open(&path)?; + let mut writer = BufWriter::with_capacity(1024, file); + let (sender, receiver) = mpsc::channel::(); + + let thread_name = format!("xserial-log-{}", path.display()); + thread::Builder::new().name(thread_name).spawn(move || { + while let Ok(line) = receiver.recv() { + if writeln!(writer, "{line}").is_err() { + break; + } + } + let _ = writer.flush(); + })?; + + Ok(Self { sender }) + } + + /// Send a line to the background writer. Non-blocking — drops the line + /// silently if the writer thread has stopped. + pub fn write_line(&self, line: &str) { + let _ = self.sender.send(line.to_owned()); + } +} + +impl Drop for LogWriter { + fn drop(&mut self) { + // Sender is dropped here, which causes the background thread's + // receiver.recv() to return Err, triggering flush + exit. + } +} + +/// Format a [`DecodedEntry`] into a log line. +/// +/// Format: `[HH:MM:SS.mmm] [IN] [pipeline] payload` +/// +/// Returns `None` for Binary and Plot entries (not meaningful as text logs). +pub fn format_data_log( + entry: &DecodedEntry, + direction: &str, + show_timestamp: bool, + show_direction: bool, + show_pipeline: bool, +) -> Option { + let data = match &entry.data { + DecodedData::Text(text) => text.clone(), + DecodedData::Hex(hex) => hex.clone(), + DecodedData::Binary(_) | DecodedData::Plot(_) => return None, + }; + + let mut parts = Vec::with_capacity(4); + if show_timestamp { + parts.push(utc_timestamp()); + } + if show_direction { + parts.push(format!("[{direction}]")); + } + if show_pipeline { + parts.push(format!("[{}]", entry.pipeline_name)); + } + parts.push(data); + + Some(parts.join(" ")) +} + +/// Format a sent text string into a log line (no `DecodedEntry` available +/// for outbound data). +pub fn format_sent_log(text: &str) -> String { + format!("{} [OUT] {text}", utc_timestamp()) +} + +fn utc_timestamp() -> String { + let since_epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let secs = since_epoch.as_secs(); + let ms = since_epoch.subsec_millis(); + let h = (secs / 3600) % 24; + let m = (secs / 60) % 60; + let s = secs % 60; + format!("{h:02}:{m:02}:{s:02}.{ms:03}") +} diff --git a/crates/xserial-gui/src/main.rs b/crates/xserial-gui/src/main.rs index 08ace89..4d01558 100644 --- a/crates/xserial-gui/src/main.rs +++ b/crates/xserial-gui/src/main.rs @@ -1,6 +1,7 @@ mod app; mod app_state; mod buffers; +mod logging; mod panels; mod perf; mod shortcuts;