diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index ff2aca8a..30682c70 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -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( diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index 0c947cbc..d89a321d 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -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 diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs new file mode 100644 index 00000000..ffd8ccc2 --- /dev/null +++ b/src-tauri/src/diagnostics.rs @@ -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 = 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, + 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 { + 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 { + 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 = 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) -> Vec { + let mut ids: Vec = ids.collect(); + ids.sort(); + ids +} + +pub(crate) fn is_app_log(name: &str) -> bool { + name.ends_with(".log") || name.ends_with(".txt") +} + +/// `donut-proxy-.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); + +impl Aliases { + fn add_all(&mut self, kind: &str, ids: Vec) { + 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= 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")); + } +} diff --git a/src-tauri/src/feedback.rs b/src-tauri/src/feedback.rs index b13813ff..5d58128e 100644 --- a/src-tauri/src/feedback.rs +++ b/src-tauri/src/feedback.rs @@ -162,18 +162,13 @@ pub fn validate_email(email: Option<&str>) -> Result, 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 { - 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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 717f6905..d70f3d59 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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; diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index c1c14f72..0665857e 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -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 { +/// 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 { 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 &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 { 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 { + 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();