feat(bridge): tee tracing output to platform log file + log_file_path_str API

Bridge already had a tracing fmt layer writing to stderr but a Flutter
desktop app launched from Explorer / RDP has no terminal attached so
those records vanish. Add a non-ANSI file appender (best-effort,
4 MiB rotation) at the platform-conventional log path so developers
and beta testers can hand-inspect output:

  * Linux:   $XDG_STATE_HOME/app.chanora/chanora_flutter/chanora.log
              (fallback ~/.local/state/...)
  * macOS:   ~/Library/Logs/app.chanora.chanora_flutter/chanora.log
  * Windows: %LOCALAPPDATA%\app.chanora\chanora_flutter\logs\chanora.log

Expose log_file_path_str() over FRB so the UI can show the path in a
'Save diagnostics' affordance later. Mobile (Android/iOS) returns an
empty string — those platforms still rely on logcat / Console.app.

No DEC-016 conflict: this is local-only, append-only, never
auto-uploaded. The in-memory log sink and export_diagnostics() path
are unchanged. The redacting layer still wraps the in-memory sink;
the new file appender consumes the same tracing events post-filter.

Trigger for this change: a ko-KR Windows 11 tester saw
'BridgeError.invalidCommand(audio not started)' with no way to find
the upstream warn record that documents which cpal call failed. Log
file is now discoverable without a terminal launch.
This commit is contained in:
EdisonJwa
2026-05-15 23:18:57 +08:00
parent ba444d94bd
commit 6a077ac7a1
+111 -5
View File
@@ -92,14 +92,120 @@ pub fn bridge_init() {
let fmt_layer = tracing_subscriber::fmt::layer().with_target(true);
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER));
let _ = tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.with(redact_layer)
.try_init();
// Also tee tracing output to a rotating file in the OS's
// standard log directory so developers and beta testers can
// hand-inspect output without launching from a terminal.
// The path is platform-conventional:
// * Linux: ~/.local/state/app.chanora/chanora_flutter/chanora.log
// * macOS: ~/Library/Logs/app.chanora.chanora_flutter/chanora.log
// * Windows: %LOCALAPPDATA%\app.chanora\chanora_flutter\logs\chanora.log
// Best-effort: if the directory cannot be created or the
// file cannot be opened, the layer is silently dropped and
// stderr remains the only sink.
let file_writer = open_log_file();
if let Some(file) = file_writer {
let file_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_ansi(false)
.with_writer(std::sync::Mutex::new(file));
let _ = tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.with(file_layer)
.with(redact_layer)
.try_init();
} else {
let _ = tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.with(redact_layer)
.try_init();
}
}
info!(target: "chanora_bridge", "bridge initialised");
if let Some(p) = log_file_path() {
info!(target: "chanora_bridge", path = %p.display(), "log file path");
}
}
/// Return the platform-conventional log-file path as a string, or
/// an empty string if the platform does not have one (mobile).
#[frb(sync)]
pub fn log_file_path_str() -> String {
log_file_path()
.map(|p| p.display().to_string())
.unwrap_or_default()
}
/// Resolve the platform-conventional log-file path. Returns `None`
/// if the platform conventions can't be honoured (e.g. neither
/// `LOCALAPPDATA` nor `HOME` is set).
fn log_file_path() -> Option<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
let base = std::env::var_os("LOCALAPPDATA")
.or_else(|| std::env::var_os("APPDATA"))?;
Some(
std::path::PathBuf::from(base)
.join("app.chanora")
.join("chanora_flutter")
.join("logs")
.join("chanora.log"),
)
}
#[cfg(target_os = "macos")]
{
let home = std::env::var_os("HOME")?;
Some(
std::path::PathBuf::from(home)
.join("Library")
.join("Logs")
.join("app.chanora.chanora_flutter")
.join("chanora.log"),
)
}
#[cfg(all(unix, not(target_os = "macos"), not(target_os = "android"), not(target_os = "ios")))]
{
let base = std::env::var_os("XDG_STATE_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local").join("state"))
})?;
Some(
base.join("app.chanora")
.join("chanora_flutter")
.join("chanora.log"),
)
}
#[cfg(any(target_os = "android", target_os = "ios"))]
{
None
}
}
/// Open the platform log file in append mode, rotating once on
/// startup so each launch begins with a fresh file. Best-effort:
/// returns `None` on any I/O error.
fn open_log_file() -> Option<std::fs::File> {
let path = log_file_path()?;
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Rotate: if the existing file exceeds 4 MiB rename it to .1 so
// we never grow unbounded. One generation is enough for
// debugging; we are not building a real log-rotation system.
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > 4 * 1024 * 1024 {
let _ = std::fs::rename(&path, path.with_extension("log.1"));
}
}
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()
}
// ---------- DTOs ----------