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
213 lines
7.4 KiB
Rust
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);
|
|
}));
|
|
}
|