mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-22 07:08:53 +02:00
feat: facts and worker logs
This commit is contained in:
1 parent
54184bda90
commit
4b0600c8eb
6 files changed
+387
-23
No files matched your search
@@ -7131,6 +7131,16 @@ test("feedback goes from the More menu to the team, with the logs the person pre
|
||||
{ description: "this session's log in the excerpt" },
|
||||
);
|
||||
|
||||
// Facts lead the excerpt; a profile shows by alias, never by its
|
||||
// name or id, in the facts and in the log lines alike.
|
||||
const profile = await createUiProfile(app, "Secret Shop Account");
|
||||
const excerpt = await app.invoke("preview_feedback_logs");
|
||||
assert.ok(excerpt.startsWith("===== facts =====\n"), excerpt);
|
||||
assert.match(excerpt, /^app=\S+ os=\S+ arch=\S+ install=\S+/m);
|
||||
assert.match(excerpt, /^profile-1 browser=wayfern /m);
|
||||
assert.ok(!excerpt.includes("Secret Shop Account"));
|
||||
assert.ok(!excerpt.includes(profile.id));
|
||||
|
||||
await app.clickSelector(`[aria-label="${en.rail.more.label}"]`);
|
||||
await app.clickSelector('[data-slot="rail-open-feedback"]');
|
||||
await app.waitFor(
|
||||
|
||||
@@ -178,6 +178,41 @@ impl AppAutoUpdater {
|
||||
true
|
||||
}
|
||||
|
||||
/// How this copy was installed, for bug reports: `portable`, `flatpak`,
|
||||
/// `snap`, `appimage`, `deb`, `rpm`, `manual`, `app` (macOS), `installer`
|
||||
/// (Windows) or `unknown`.
|
||||
pub fn install_kind() -> &'static str {
|
||||
if crate::app_dirs::is_portable() {
|
||||
return "portable";
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
match crate::app_dirs::linux_sandbox() {
|
||||
Some(crate::app_dirs::LinuxSandbox::Flatpak) => "flatpak",
|
||||
Some(crate::app_dirs::LinuxSandbox::Snap) => "snap",
|
||||
None => match Self::instance().detect_linux_installation_method() {
|
||||
LinuxInstallationMethod::AppImage => "appimage",
|
||||
LinuxInstallationMethod::Deb => "deb",
|
||||
LinuxInstallationMethod::Rpm => "rpm",
|
||||
LinuxInstallationMethod::Manual => "manual",
|
||||
LinuxInstallationMethod::Unknown => "unknown",
|
||||
},
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"app"
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
"installer"
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current app version from build-time injection
|
||||
pub fn get_current_version() -> String {
|
||||
// Use build-time injected version instead of CARGO_PKG_VERSION
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! What a bug report carries: anonymous facts about this install, then the
|
||||
//! newest app logs and proxy worker logs, redacted. Profiles, proxies, VPNs
|
||||
//! and groups appear by alias (`profile-3`), the same alias in the facts and
|
||||
//! in every log line, so one profile's lines can be followed without its name
|
||||
//! or id. Feedback attaches it, `read_diagnostics` hands it to an agent, and
|
||||
//! the log export copies it.
|
||||
|
||||
use regex_lite::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// The proxy worker logs take at most this share of the budget.
|
||||
const WORKER_LOG_SHARE: usize = 4;
|
||||
/// Profiles listed one per line; the counts cover all of them.
|
||||
const MAX_LISTED_PROFILES: usize = 40;
|
||||
|
||||
static ANY_UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b")
|
||||
.expect("valid UUID regex")
|
||||
});
|
||||
|
||||
/// Account facts, read before the blocking part.
|
||||
struct Account {
|
||||
signed_in: bool,
|
||||
plan: Option<String>,
|
||||
sync: &'static str,
|
||||
}
|
||||
|
||||
/// The excerpt, at most `max_bytes`. Facts first, then the app logs and the
|
||||
/// worker logs, each file under a `===== name =====` header.
|
||||
pub async fn excerpt(log_dir: PathBuf, max_bytes: usize) -> std::io::Result<String> {
|
||||
let user = crate::cloud_auth::CLOUD_AUTH.get_user().await;
|
||||
let sync = match crate::sync::account_server::current().await {
|
||||
None => "off",
|
||||
Some(source) => match source.kind() {
|
||||
crate::sync::account_server::SyncSourceKind::Cloud => "cloud",
|
||||
crate::sync::account_server::SyncSourceKind::Account => "account_server",
|
||||
crate::sync::account_server::SyncSourceKind::SelfHosted => "self_hosted",
|
||||
},
|
||||
};
|
||||
let account = Account {
|
||||
signed_in: user.is_some(),
|
||||
plan: user.map(|state| state.user.plan),
|
||||
sync,
|
||||
};
|
||||
tokio::task::spawn_blocking(move || build(&log_dir, max_bytes, &account))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
fn build(log_dir: &Path, max_bytes: usize, account: &Account) -> std::io::Result<String> {
|
||||
let profiles = crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap_or_default();
|
||||
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
|
||||
let vpns = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|storage| storage.list_configs().ok())
|
||||
.unwrap_or_default();
|
||||
let groups = crate::group_manager::GROUP_MANAGER
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|manager| manager.get_all_groups().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut aliases = Aliases::default();
|
||||
let mut profile_ids: Vec<String> = profiles.iter().map(|p| p.id.to_string()).collect();
|
||||
profile_ids.sort();
|
||||
aliases.add_all("profile", profile_ids);
|
||||
aliases.add_all("proxy", sorted(proxies.iter().map(|p| p.id.clone())));
|
||||
aliases.add_all("vpn", sorted(vpns.iter().map(|v| v.id.clone())));
|
||||
aliases.add_all("group", sorted(groups.iter().map(|g| g.id.clone())));
|
||||
|
||||
let proxy_types: HashMap<&str, &str> = proxies
|
||||
.iter()
|
||||
.map(|p| (p.id.as_str(), p.proxy_settings.proxy_type.as_str()))
|
||||
.collect();
|
||||
let facts = facts(
|
||||
&profiles,
|
||||
&proxy_types,
|
||||
vpns.len(),
|
||||
groups.len(),
|
||||
account,
|
||||
&aliases,
|
||||
);
|
||||
|
||||
let prepare = |raw: &str| crate::log_redaction::text(&aliases.apply(raw));
|
||||
let mut out = format!("===== facts =====\n{facts}");
|
||||
let worker_budget = max_bytes.saturating_sub(out.len()) / WORKER_LOG_SHARE;
|
||||
let workers = crate::settings_manager::recent_log_files(
|
||||
&std::env::temp_dir(),
|
||||
worker_budget,
|
||||
is_worker_log,
|
||||
&prepare,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let app_budget = max_bytes.saturating_sub(out.len() + workers.len());
|
||||
let app = if log_dir.exists() {
|
||||
crate::settings_manager::recent_log_files(log_dir, app_budget, is_app_log, &prepare)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
out.push_str(&app);
|
||||
out.push_str(&workers);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn sorted(ids: impl Iterator<Item = String>) -> Vec<String> {
|
||||
let mut ids: Vec<String> = ids.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
pub(crate) fn is_app_log(name: &str) -> bool {
|
||||
name.ends_with(".log") || name.ends_with(".txt")
|
||||
}
|
||||
|
||||
/// `donut-proxy-<id>.log`, one per worker (proxy, VPN and Xray routes alike).
|
||||
fn is_worker_log(name: &str) -> bool {
|
||||
name.starts_with("donut-proxy-") && name.ends_with(".log")
|
||||
}
|
||||
|
||||
fn facts(
|
||||
profiles: &[crate::profile::BrowserProfile],
|
||||
proxy_types: &HashMap<&str, &str>,
|
||||
vpn_count: usize,
|
||||
group_count: usize,
|
||||
account: &Account,
|
||||
aliases: &Aliases,
|
||||
) -> String {
|
||||
let info = crate::settings_manager::get_system_info();
|
||||
let auto_updates_disabled = crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|s| s.disable_auto_updates)
|
||||
.unwrap_or(false);
|
||||
let wayfern = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance()
|
||||
.get_downloaded_versions("wayfern");
|
||||
|
||||
let running = |p: &crate::profile::BrowserProfile| {
|
||||
p.process_id
|
||||
.is_some_and(crate::proxy_storage::is_process_running)
|
||||
};
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"app={} os={} arch={} install={} auto_updates={}",
|
||||
info.app_version,
|
||||
info.os,
|
||||
info.arch,
|
||||
crate::app_auto_updater::AppAutoUpdater::install_kind(),
|
||||
if auto_updates_disabled { "off" } else { "on" },
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"account={} plan={} sync={}",
|
||||
if account.signed_in {
|
||||
"signed_in"
|
||||
} else {
|
||||
"signed_out"
|
||||
},
|
||||
account.plan.as_deref().unwrap_or("-"),
|
||||
account.sync,
|
||||
);
|
||||
let _ = writeln!(out, "wayfern_installed=[{}]", wayfern.join(" "));
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"profiles={} running={} ephemeral={} with_proxy={} with_vpn={} proxies={} vpns={} groups={}",
|
||||
profiles.len(),
|
||||
profiles.iter().filter(|p| running(p)).count(),
|
||||
profiles.iter().filter(|p| p.ephemeral).count(),
|
||||
profiles.iter().filter(|p| p.proxy_id.is_some()).count(),
|
||||
profiles.iter().filter(|p| p.vpn_id.is_some()).count(),
|
||||
proxy_types.len(),
|
||||
vpn_count,
|
||||
group_count,
|
||||
);
|
||||
let _ = writeln!(out, "{}", aliases.apply(&crate::process_report::summary()));
|
||||
|
||||
let mut listed: Vec<&crate::profile::BrowserProfile> = profiles.iter().collect();
|
||||
listed.sort_by_key(|p| p.id.to_string());
|
||||
for profile in listed.iter().take(MAX_LISTED_PROFILES) {
|
||||
let proxy = profile
|
||||
.proxy_id
|
||||
.as_deref()
|
||||
.map(|id| {
|
||||
format!(
|
||||
"{}:{}",
|
||||
aliases.apply(id),
|
||||
proxy_types.get(id).copied().unwrap_or("missing")
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let sync = serde_json::to_value(profile.sync_mode)
|
||||
.ok()
|
||||
.and_then(|value| value.as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{} browser={} version={} running={} ephemeral={} proxy={} vpn={} group={} sync={} extensions={}",
|
||||
aliases.apply(&profile.id.to_string()),
|
||||
profile.browser,
|
||||
profile.version,
|
||||
running(profile),
|
||||
profile.ephemeral,
|
||||
proxy,
|
||||
profile.vpn_id.as_deref().map_or("-".to_string(), |id| aliases.apply(id)),
|
||||
profile.group_id.as_deref().map_or("-".to_string(), |id| aliases.apply(id)),
|
||||
sync,
|
||||
profile.extension_group_id.is_some(),
|
||||
);
|
||||
}
|
||||
if profiles.len() > MAX_LISTED_PROFILES {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"… {} more profiles",
|
||||
profiles.len() - MAX_LISTED_PROFILES
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Ids by alias. An id the table does not know is left for the redaction.
|
||||
#[derive(Default)]
|
||||
struct Aliases(HashMap<String, String>);
|
||||
|
||||
impl Aliases {
|
||||
fn add_all(&mut self, kind: &str, ids: Vec<String>) {
|
||||
for (index, id) in ids.into_iter().enumerate() {
|
||||
self
|
||||
.0
|
||||
.insert(id.to_ascii_lowercase(), format!("{kind}-{}", index + 1));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&self, text: &str) -> String {
|
||||
ANY_UUID_RE
|
||||
.replace_all(text, |caps: ®ex_lite::Captures<'_>| {
|
||||
let id = &caps[0];
|
||||
self
|
||||
.0
|
||||
.get(&id.to_ascii_lowercase())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_id_gets_the_same_alias_everywhere_and_unknown_ids_stay_for_redaction() {
|
||||
let mut aliases = Aliases::default();
|
||||
aliases.add_all(
|
||||
"profile",
|
||||
vec![
|
||||
"11111111-1111-4111-8111-111111111111".to_string(),
|
||||
"22222222-2222-4222-8222-222222222222".to_string(),
|
||||
],
|
||||
);
|
||||
let line = "Browser launched profile=22222222-2222-4222-8222-222222222222 \
|
||||
proxy=33333333-3333-4333-8333-333333333333 \
|
||||
again=22222222-2222-4222-8222-222222222222";
|
||||
let aliased = aliases.apply(line);
|
||||
assert_eq!(
|
||||
aliased,
|
||||
"Browser launched profile=profile-2 proxy=33333333-3333-4333-8333-333333333333 again=profile-2"
|
||||
);
|
||||
assert_eq!(
|
||||
crate::log_redaction::text(&aliased),
|
||||
"Browser launched profile=profile-2 proxy=<redacted-identifier> again=profile-2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_uppercase_id_matches_its_alias() {
|
||||
let mut aliases = Aliases::default();
|
||||
aliases.add_all(
|
||||
"vpn",
|
||||
vec!["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
aliases.apply("vpn=AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"),
|
||||
"vpn=vpn-1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_donut_proxy_worker_logs_are_worker_logs() {
|
||||
assert!(is_worker_log("donut-proxy-0a1b.log"));
|
||||
assert!(!is_worker_log("donut-proxy-0a1b.txt"));
|
||||
assert!(!is_worker_log("other.log"));
|
||||
assert!(is_app_log("DonutBrowser.log"));
|
||||
assert!(!is_app_log("settings.json"));
|
||||
}
|
||||
}
|
||||
@@ -162,18 +162,13 @@ pub fn validate_email(email: Option<&str>) -> Result<Option<String>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The log excerpt feedback carries: the newest lines, redacted, at most
|
||||
/// `MAX_LOG_BYTES`. Empty when the app has not written a log yet.
|
||||
/// The log excerpt feedback carries: anonymous facts, then the newest app and
|
||||
/// proxy worker log lines, redacted, at most `MAX_LOG_BYTES`
|
||||
/// (`diagnostics::excerpt`).
|
||||
pub async fn log_excerpt(dir: PathBuf) -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !dir.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
crate::settings_manager::recent_logs(&dir, MAX_LOG_BYTES)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::backend_error_with_detail("FEEDBACK_LOGS_UNREADABLE", e))?
|
||||
.map_err(|e| crate::backend_error_with_detail("FEEDBACK_LOGS_UNREADABLE", e))
|
||||
crate::diagnostics::excerpt(dir, MAX_LOG_BYTES)
|
||||
.await
|
||||
.map_err(|e| crate::backend_error_with_detail("FEEDBACK_LOGS_UNREADABLE", e))
|
||||
}
|
||||
|
||||
/// Whether an agent may send one more piece of feedback this hour. Counts the
|
||||
|
||||
@@ -133,6 +133,7 @@ mod browser_version_manager;
|
||||
mod cdp_target;
|
||||
mod checksum;
|
||||
mod default_browser;
|
||||
mod diagnostics;
|
||||
mod dialer;
|
||||
pub mod dns_blocklist;
|
||||
mod downloaded_browsers_registry;
|
||||
|
||||
@@ -994,18 +994,30 @@ pub async fn save_app_settings(
|
||||
|
||||
const TRUNCATED_MARK: &str = "[…truncated — older content elided…]\n";
|
||||
|
||||
/// The newest log files, redacted, each under a `===== name =====` header and
|
||||
/// in chronological order so the freshest lines are at the bottom. At most
|
||||
/// `max_bytes`: the oldest file that only fits in part keeps its last whole
|
||||
/// lines.
|
||||
pub(crate) fn recent_logs(dir: &Path, max_bytes: usize) -> std::io::Result<String> {
|
||||
/// The newest files in `dir` whose name `accept` takes, each under a
|
||||
/// `===== name =====` header and in chronological order so the freshest lines
|
||||
/// are at the bottom. Every file's content goes through `prepare`, which must
|
||||
/// redact. At most `max_bytes`: the oldest file that only fits in part keeps
|
||||
/// its last whole lines.
|
||||
pub(crate) fn recent_log_files(
|
||||
dir: &Path,
|
||||
max_bytes: usize,
|
||||
accept: impl Fn(&str) -> bool,
|
||||
prepare: &dyn Fn(&str) -> String,
|
||||
) -> std::io::Result<String> {
|
||||
let mut entries: Vec<(PathBuf, std::time::SystemTime)> = std::fs::read_dir(dir)?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let accepted = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(&accept);
|
||||
if !accepted || !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let modified = entry.metadata().ok()?.modified().ok()?;
|
||||
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
(path.is_file() && (ext == "log" || ext == "txt")).then_some((path, modified))
|
||||
Some((path, modified))
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by_key(|(_, modified)| std::cmp::Reverse(*modified));
|
||||
@@ -1023,7 +1035,7 @@ pub(crate) fn recent_logs(dir: &Path, max_bytes: usize) -> std::io::Result<Strin
|
||||
let Ok(raw) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let mut content = crate::log_redaction::text(&String::from_utf8_lossy(&raw));
|
||||
let mut content = prepare(&String::from_utf8_lossy(&raw));
|
||||
if !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
@@ -1055,16 +1067,17 @@ fn last_lines(text: &str, max_bytes: usize) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the most recent log files concatenated into a single string,
|
||||
/// suitable for paste-into-issue-tracker. Capped at 5 MB to keep clipboard
|
||||
/// payloads sane.
|
||||
/// The diagnostics excerpt (facts, app logs, worker logs), for
|
||||
/// paste-into-issue-tracker. Capped at 5 MB to keep clipboard payloads sane.
|
||||
#[tauri::command]
|
||||
pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
let dir = crate::app_dirs::log_dir(&app_handle);
|
||||
if !dir.exists() {
|
||||
return Err("Log directory does not exist yet".to_string());
|
||||
}
|
||||
recent_logs(&dir, 5 * 1024 * 1024).map_err(|e| format!("Failed to read log dir: {e}"))
|
||||
crate::diagnostics::excerpt(dir, 5 * 1024 * 1024)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read log dir: {e}"))
|
||||
}
|
||||
|
||||
/// Reveal the log directory in the OS file manager.
|
||||
@@ -2362,6 +2375,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn recent_logs(dir: &Path, max_bytes: usize) -> std::io::Result<String> {
|
||||
recent_log_files(
|
||||
dir,
|
||||
max_bytes,
|
||||
crate::diagnostics::is_app_log,
|
||||
&crate::log_redaction::text,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_log(dir: &Path, name: &str, body: &[u8], age_secs: u64) {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, body).unwrap();
|
||||
|
||||
Reference in new issue
Block a user