storebound/apps/desktop/src-tauri/src/logs.rs
CRT bd3ab20e1d Desktop: never a blank window (frozen-prototype fix, error screens, logs, state migration, boot check)
The signed release showed only its background colour and never started the sidecar. Tauri's freezePrototype
freezes Object.prototype before the bundle runs; d3-color and d3-format (under the visx charts) assign
`constructor` / `toString` on plain objects while loading, which throws on a frozen prototype, so the bundle
stopped before React drew anything. The saved state was not the cause.

- build/freeze-safe.ts: Vite plugin that rewrites those assignments in dependencies into a helper that defines
  the property when the frozen prototype would refuse it
- error boundaries for the window, each screen and each app tab (Copy details, Reload, Reset saved state with a
  backup); public/boot.js shows the same panel when the bundle fails or the window stays empty
- logs/host.log (start, saved state, sidecar start/stop, panics) and logs/webview.log (log_webview), rotated,
  home as ~, keys, tokens and e-mail addresses replaced
- state.json `version` 2 with an explicit 1 -> 2 migration (original kept as state.json.v1.bak), per-entry
  tolerant parsing, partial settings keep their fields, unreadable files kept aside
- folders that are gone: "Folder not found" with Locate... (project_locate) / Remove; portfolio reads skip them
- tools/desktop-boot-check.ts: the built window with the release CSP and the frozen prototype, a mocked host from
  the state fixtures (empty, D1 with folders gone, D3) and the real sidecar, through every screen; in bun run ci,
  the Forgejo CI workflow and the macOS release
2026-10-11 09:36:34 +08:00

213 lines
7.4 KiB
Rust

//! Local diagnostics in `<app data>/logs`: `host.log` (this process: start, saved state, sidecar, panics) and
//! `webview.log` (uncaught errors of the window, sent through the `log_webview` command). Each file is rotated at
//! MAX_BYTES (one older `.1.log` kept). Every line goes through `sanitize`: the home folder shows as `~`, and keys,
//! tokens, long secrets and e-mail addresses are replaced, so a log can be attached to a bug report as it is.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub const DIR: &str = "logs";
pub const HOST: &str = "host.log";
pub const WEBVIEW: &str = "webview.log";
pub const MAX_BYTES: u64 = 512 * 1024;
/// Longest single entry kept (the rest is cut).
pub const MAX_ENTRY: usize = 16 * 1024;
struct Logs {
dir: PathBuf,
home: Option<String>,
lock: Mutex<()>,
}
static LOGS: OnceLock<Logs> = OnceLock::new();
/// Sets the log folder once (the first call wins).
pub fn init(data_dir: &Path, home: Option<String>) {
let _ = LOGS.set(Logs { dir: data_dir.join(DIR), home, lock: Mutex::new(()) });
}
pub fn dir() -> Option<PathBuf> {
LOGS.get().map(|l| l.dir.clone())
}
/// One line in host.log (no-op before `init`).
pub fn host(level: &str, message: &str) {
write(HOST, "host", level, message);
}
/// One entry in webview.log.
pub fn webview(level: &str, message: &str) {
write(WEBVIEW, "webview", level, message);
}
fn write(file: &str, source: &str, level: &str, message: &str) {
let Some(l) = LOGS.get() else { return };
let _g = l.lock.lock();
let level = match level {
"error" | "warn" | "info" | "debug" => level,
_ => "info",
};
let text = sanitize(message, l.home.as_deref());
let line = entry(&crate::store::now_iso(), source, level, &text);
let _ = append(&l.dir, file, &line);
}
/// `<time> <level> [<source>] <text>`; continuation lines are indented.
pub fn entry(at: &str, source: &str, level: &str, text: &str) -> String {
let mut t: String = text.chars().take(MAX_ENTRY).collect();
if t.len() < text.len() {
t.push_str(" [cut]");
}
format!("{at} {level:<5} [{source}] {}\n", t.trim_end().replace('\n', "\n "))
}
/// Appends, rotating first when the file would pass MAX_BYTES.
pub fn append(dir: &Path, file: &str, line: &str) -> std::io::Result<()> {
fs::create_dir_all(dir)?;
let path = dir.join(file);
if fs::metadata(&path).map(|m| m.len() + line.len() as u64 > MAX_BYTES).unwrap_or(false) {
let old = dir.join(file.replace(".log", ".1.log"));
let _ = fs::remove_file(&old);
fs::rename(&path, &old)?;
}
OpenOptions::new().create(true).append(true).open(&path)?.write_all(line.as_bytes())
}
const SECRET_KEYS: &[&str] = &["secret", "token", "password", "passwd", "private_key", "privatekey", "apikey", "api_key", "authorization", "credential", "p8"];
/// The home folder as `~`, then keys, tokens and e-mail addresses replaced. Control characters other than newline and
/// tab are dropped.
pub fn sanitize(text: &str, home: Option<&str>) -> String {
let mut s: String = text.chars().filter(|c| !c.is_control() || *c == '\n' || *c == '\t').collect();
if let Some(h) = home.map(|h| h.trim_end_matches(['/', '\\'])).filter(|h| h.len() > 1) {
s = s.replace(h, "~");
}
s = redact_pem(&s);
s = redact_assignments(&s);
redact_words(&s)
}
fn redact_pem(s: &str) -> String {
let mut out = String::new();
let mut rest = s;
while let Some(i) = rest.find("-----BEGIN") {
out.push_str(&rest[..i]);
let after = &rest[i..];
match after.find("-----END").and_then(|e| after[e + 8..].find("-----").map(|k| e + 8 + k + 5)) {
Some(end) => {
out.push_str("[redacted key]");
rest = &after[end..];
}
None => {
out.push_str("[redacted key]");
rest = "";
}
}
}
out.push_str(rest);
out
}
/// `token=...`, `"password": "..."`, `Authorization: Bearer ...`: the value goes.
fn redact_assignments(s: &str) -> String {
let lower = s.to_ascii_lowercase();
let b = s.as_bytes();
let mut cut: Vec<(usize, usize)> = Vec::new();
for k in SECRET_KEYS {
let mut from = 0;
while let Some(i) = lower[from..].find(k) {
let mut j = i + from + k.len();
from = j;
// rest of the key name ("tokenId", "secret_key"), a closing quote, spaces, then ':' or '='
while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_' || b[j] == b'-') {
j += 1;
}
while j < b.len() && (b[j] == b'"' || b[j] == b'\'' || b[j] == b' ') {
j += 1;
}
if j >= b.len() || (b[j] != b':' && b[j] != b'=') {
continue;
}
j += 1;
while j < b.len() && (b[j] == b' ' || b[j] == b'"' || b[j] == b'\'') {
j += 1;
}
if lower[j..].starts_with("bearer ") || lower[j..].starts_with("basic ") {
j += lower[j..].find(' ').unwrap_or(0) + 1;
}
let start = j;
while j < b.len() && !matches!(b[j], b'"' | b'\'' | b',' | b'}' | b' ' | b'\n' | b'&' | b';') {
j += 1;
}
if j > start {
cut.push((start, j));
}
}
}
cut.sort();
let mut out = String::new();
let mut at = 0;
for (a, z) in cut {
if a < at {
continue;
}
out.push_str(&s[at..a]);
out.push_str("[redacted]");
at = z;
}
out.push_str(&s[at..]);
out
}
/// Long random-looking words (keys, JWTs, hashes of 32+ characters with letters and digits) and e-mail addresses.
fn redact_words(s: &str) -> String {
let is_word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '/' | '=' | '.' | '@');
let mut out = String::with_capacity(s.len());
let mut word = String::new();
let flush = |w: &mut String, out: &mut String| {
if !w.is_empty() {
out.push_str(&judge(w));
w.clear();
}
};
for c in s.chars() {
if is_word(c) {
word.push(c);
} else {
flush(&mut word, &mut out);
out.push(c);
}
}
flush(&mut word, &mut out);
out
}
fn judge(w: &str) -> String {
if let Some((user, domain)) = w.split_once('@') {
if !user.is_empty() && domain.contains('.') && !domain.starts_with('.') {
return "[email]".into();
}
}
let mixed = |p: &str| p.chars().any(|c| c.is_ascii_alphabetic()) && p.chars().any(|c| c.is_ascii_digit());
// a long base64 / hex run (may hold '/' or '+'): not a path, not a dotted name
if w.len() >= 40 && !w.starts_with(['/', '~']) && !w.contains('.') && mixed(w) {
return "[redacted]".into();
}
// paths and dotted names stay (file names in stacks); one long run of key-like characters inside them goes
for part in w.split(['/', '.']) {
if part.len() >= 32 && mixed(part) {
return w.replace(part, "[redacted]");
}
}
w.to_string()
}
/// Records panics in host.log (release builds abort right after).
pub fn panic_hook() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
host("error", &format!("panic: {info}"));
prev(info);
}));
}