refactor: better logs

This commit is contained in:
zhom committed 2026-10-10 01:11:04 +04:00
1 parent 2604994507
commit bf625fac2c
91 files changed
+4353 -3239

No files matched your search

+16 -3
View File
@@ -77,7 +77,7 @@ donutbrowser/
│ │ ├── extension_fetch.rs # Web Store link/id and direct .crx/.zip import, CRX3 unwrapping
│ │ ├── group_manager.rs # Profile group management
│ │ ├── synchronizer.rs # Real-time profile synchronizer (pause/resume, hold a follower out, window layouts)
│ │ ├── daemon/ # Background daemon + tray icon (currently disabled)
│ │ ├── legacy_autostart.rs # Removes the autostart entry the removed tray daemon (0.14-0.17) left behind
│ │ └── cloud_auth.rs # Cloud authentication
│ ├── tests/ # Integration tests
│ └── Cargo.toml # Rust dependencies
@@ -167,11 +167,24 @@ evidence to the owning suite. `e2e:smoke` fails if command registration and the
Three log surfaces, in order of usefulness:
- Donut Browser GUI: `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI, Tauri, `browser_runner`, `proxy_manager`, and `sync` all log here. Search for `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
- donut-proxy worker: `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. Upstream rejects (status lines like `HTTP/1.1 402 user reached limit`) and tunnel errors are at WARN. Accepted CONNECT requests are at DEBUG on purpose, so the log is not a list of visited sites; use `RUST_LOG=donut_proxy=debug` to see them. Anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload` warning (those bytes would be dropped without forwarding) marks a real bug in `handle_connect_from_buffer` if it ever fires.
- Donut Browser GUI: `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI, Tauri, `browser_runner`, `proxy_manager`, and `sync` all log here, and uncaught frontend errors arrive with the `webview` target. Search for `profile=<uuid>` to follow a launch: `Proxy worker started`, then `Browser launched` (or `Browser launch failed` / `Browser launch blocked`), then `Browser stopped` or `Browser exited`. Dev builds write to `DonutBrowserDev.log` instead.
- donut-proxy worker: `$TMPDIR/donut-proxy-<config_id>.log`. One file per worker process (each profile launch spawns a fresh one); same line shape as the app log, with UTC timestamps. Its first line is `Worker started kind=proxy|vpn id= pid= listen= upstream=<scheme://host:port|direct> ...` (never credentials) and, when it stops on its own, its last is `Worker exiting ... reason=`; a worker the app kills writes no last line, so read the app's `Proxy worker stopped id=<id>` instead. Map a worker to its launch via `Proxy worker started id=<id> ... profile=<uuid>` and `Proxy worker stopped id=<id> ... reason=...` (`browser_exited(<browser pid>)`, `orphan_at_startup`, ...) in DonutBrowser.log. Upstream rejects (status lines like `HTTP/1.1 402 user reached limit`) and tunnel errors are at WARN as `CONNECT tunnel failed err="..." suppressed_30s=N`, at most once per 30s per message, with the destination host replaced by `<target>`. Destinations (accepted CONNECT requests, per-request detail) are at DEBUG or TRACE on purpose, so the log is not a list of visited sites; set `RUST_LOG=donutbrowser_lib::proxy_server=debug` (or `=trace`) in the app's environment to see them; it overrides the INFO default.
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
### Writing log lines
Agents read these files; people rarely do. Every line must tell an agent something it cannot get elsewhere, at the lowest byte cost.
- Line shape: `[2026-10-08 17:53:49.362][sync::scheduler][INFO] Profile synced profile=<uuid> files=12 bytes=4096 elapsed_ms=812`. The target drops the `donutbrowser_lib::` prefix (`app` is `lib.rs`). Start with what happened, then the facts as `key=value`. Quote values that can hold spaces: `err="..."`.
- Each session opens with `Donut started version=... os=... pid=...`, and closes with `Donut exiting browsers=[...] proxy_workers=[...] ...`, which lists what outlives the app as `id@pid` (`process_report.rs`). The same fields follow `Found from an earlier run` at startup.
- Name entities by id: `profile=<uuid>`, `proxy=<id>`, `vpn=<id>`, `group=<id>`, `pid=`. Never log profile names, emails, visited URLs, or credentials: people attach these files to public issues.
- Levels: `error` = an operation the person asked for failed, or data is at risk. `warn` = degraded or unexpected, but work continues. `info` = a state change worth knowing later (start, launch, spawn, exit, kill, sync result, install), once per change. `debug` = per-poll, per-request, per-file and steady-state detail ("already in sync", "using cache", "nothing to do"). The file keeps `info` and above.
- A loop or retry never logs each tick or attempt. A failure that can repeat goes through `log_streak`: `Streak` (one line when it starts, one reminder an hour, one line with the count when it recovers; a failure within a minute of a recovery continues the streak as a flap), `KeyedStreak` when the failure belongs to one entity (one streak per `profile=`/`key=`, so one entity's success never hides another's failure), or `Throttle` for events that are normal one at a time but noisy in bulk, such as refused requests (one line per key per window, with `suppressed=N`).
- No banners and no `Starting X...` line before a result line: put `elapsed_ms` on the result line.
- Log an error chain once (`err=...`); do not wrap it in the same context again (`Failed to X: Failed to X: ...`).
- Processes: log every spawn with `pid` and what it serves, and every exit or kill with `pid` and the exit status. Child process output (browser stdout, worker stderr) is `debug` unless it explains a failure.
## Code Quality
- Don't leave comments that don't add value
+1 -1
View File
@@ -1706,7 +1706,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.31.3"
version = "0.32.0"
dependencies = [
"aes 0.9.3",
"aes-gcm 0.11.1",
+5 -1
View File
@@ -345,9 +345,13 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
// The new-profile form (which needs a downloaded browser and its release
// types, so it renders here and not in the UI suite): session restore is
// on by default and the checkbox is a live control.
// on by default and the checkbox is a live control. It sits under the
// collapsed advanced options.
await app.clickSelector('[aria-label="Profiles"]');
await app.clickText("New");
await app.clickText(en.createProfile.advancedOptions, {
roles: ["button"],
});
const restoreChecked = () =>
app.execute(
`return document.querySelector("#restore-session")?.getAttribute("aria-checked") ?? null;`,
+23 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import { appFromEnvironment, withApp } from "../lib/app.mjs";
@@ -351,6 +351,28 @@ test("tray labels, hide-to-tray, and confirmed quit follow the native lifecycle"
);
app.session = null;
await exitingSession.close().catch(() => {});
// Agents read this file: each session opens with its facts, closes with
// what outlives it, and no line repeats the crate name.
const logDir = path.join(app.dataRoot, "logs");
const logText = (
await Promise.all(
(
await readdir(logDir)
)
.filter((name) => name.endsWith(".log"))
.map((name) => readFile(path.join(logDir, name), "utf8")),
)
).join("\n");
assert.match(
logText,
/\[app\]\[INFO\] Donut started version=\S+ os="[^"]+" arch=\S+ pid=\d+ /,
);
assert.match(
logText,
/\[app\]\[INFO\] Donut exiting browsers=\[\] proxy_workers=\[\] vpn_workers=\[\] xray_workers=\[\]/,
);
assert.doesNotMatch(logText, /donutbrowser_lib::/);
} catch (error) {
await app.capture("failure");
throw error;
+37 -1
View File
@@ -60,6 +60,25 @@ async function tableRows(app, tableId) {
);
}
/** Selection checkboxes, and the browser icons that stand in for them, that sit
* more than half a pixel off the center of their cell. */
async function offCenterSelectionControls(app, tableSelector) {
return app.execute(
`return [...document.querySelectorAll(arguments[0] + ' :is(th, td) :is([role="checkbox"], [role="checkbox"] + [aria-hidden="true"] > svg)')]
.map((node) => {
const cell = node.closest("th, td").getBoundingClientRect();
const rect = node.getBoundingClientRect();
return {
control: node.getAttribute("aria-label") ?? "icon",
x: rect.left + rect.width / 2 - (cell.left + cell.width / 2),
y: rect.top + rect.height / 2 - (cell.top + cell.height / 2),
};
})
.filter(({ x, y }) => Math.abs(x) > 0.5 || Math.abs(y) > 0.5);`,
[tableSelector],
);
}
async function tableShiftClick(app, selector) {
// tauri-wd 0.2.1 omits modifiers from pointer events. Send the click with
// Shift through the native WebView; the real checkbox handler selects rows.
@@ -479,6 +498,10 @@ test("profile table filters, range selection, view settings, and keyboard edits
);
assert.deepEqual(chrome.controls, ["0px", "0px", "0px"]);
assert.equal(chrome.rowRules, 0, "rows are not divided by rules");
assert.deepEqual(
await offCenterSelectionControls(app, '[data-table-id="profiles"]'),
[],
);
const row = (index) => `tr[data-table-row="${profiles[index].id}"]`;
await app.clickSelector(`${row(0)} [role="checkbox"]`);
await tableShiftClick(app, `${row(3)} [role="checkbox"]`);
@@ -502,7 +525,9 @@ test("profile table filters, range selection, view settings, and keyboard edits
/4/,
);
const searchSelector = `input[placeholder="${en.header.searchPlaceholder}"]`;
await app.fillSelector(searchSelector, "Table 02");
// Quoted: an unquoted "02" is its own term, which also prefix-matches
// any profile whose random id starts with 02.
await app.fillSelector(searchSelector, '"Table 02"');
await app.waitFor(
async () =>
JSON.stringify(await tableRows(app, "profiles")) ===
@@ -746,6 +771,10 @@ test("management tables share search, filters, selection, and saved views", asyn
async () => (await tableRows(app, "proxies")).length === 3,
{ description: "proxy table" },
);
assert.deepEqual(
await offCenterSelectionControls(app, '[data-table-id="proxies"]'),
[],
);
const proxyRowHeight = () =>
app.execute(
`return document.querySelector('[data-table-id="proxies"] tr[data-table-row]')?.getBoundingClientRect().height;`,
@@ -2919,6 +2948,13 @@ test("the agent page guides setup, then shows agents, requests, notes, take-over
const row = await waitPainted(`agent-profile-${profile.id}`);
assert.match(row, /Agent UI Profile/);
assert.match(row, /LOCATOR_NO_MATCH/);
assert.deepEqual(
await offCenterSelectionControls(
app,
'[data-testid="agent-profiles"]',
),
[],
);
await app.clickSelector(
`[data-testid="agent-profile-${profile.id}"] [data-testid="agent-take-over"]`,
);
+1 -1
View File
@@ -864,7 +864,7 @@ fn profile_exists(profile_id: &str) -> Result<(), String> {
let profiles = crate::profile::manager::ProfileManager::instance()
.list_profiles()
.map_err(|e| {
log::warn!("[agent-console] profiles could not be read: {e}");
log::warn!("Agent console: profile list failed err=\"{e}\"");
crate::backend_error("INTERNAL_ERROR")
})?;
if profiles.iter().any(|p| p.id.to_string() == profile_id) {
+52 -19
View File
@@ -4,6 +4,22 @@ use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::log_streak::Streak;
static WAYFERN_VERSION_FETCH: Streak = Streak::new(module_path!(), "Wayfern version fetch");
static LAST_WAYFERN_VERSION: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
fn wayfern_version_changed(version: &str) -> bool {
let mut last = LAST_WAYFERN_VERSION
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if last.as_deref() == Some(version) {
return false;
}
*last = Some(version.to_string());
true
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionComponent {
pub major: u32,
@@ -156,12 +172,12 @@ impl ApiClient {
let content = fs::read_to_string(&cache_file).ok()?;
if let Ok(cached) = serde_json::from_str::<CachedVersionData>(&content) {
log::info!("Using cached versions for {browser}");
log::debug!("Browser versions from cache browser={browser}");
return Some(cached.releases);
}
if let Ok(legacy_versions) = serde_json::from_str::<Vec<String>>(&content) {
log::info!("Using legacy cached versions for {browser}; upgrading in-memory");
log::debug!("Browser versions from legacy cache browser={browser}");
let releases: Vec<BrowserRelease> = legacy_versions
.into_iter()
.map(|version| BrowserRelease {
@@ -214,7 +230,10 @@ impl ApiClient {
let content = serde_json::to_string_pretty(&cached_data)?;
fs::write(&cache_file, content)?;
log::info!("Cached {} versions for {}", releases.len(), browser);
log::debug!(
"Browser versions cached browser={browser} count={}",
releases.len()
);
Ok(())
}
@@ -246,7 +265,7 @@ impl ApiClient {
let content = serde_json::to_string_pretty(&cached_data)?;
fs::write(&cache_file, content)?;
log::info!("Cached Wayfern version: {}", version_info.version);
log::debug!("Wayfern version cached version={}", version_info.version);
Ok(())
}
@@ -257,12 +276,14 @@ impl ApiClient {
) -> Result<WayfernVersionInfo, Box<dyn std::error::Error + Send + Sync>> {
if !no_caching {
if let Some(cached_version) = self.load_cached_wayfern_version() {
log::info!("Using cached Wayfern version: {}", cached_version.version);
log::debug!(
"Wayfern version from cache version={}",
cached_version.version
);
return Ok(cached_version);
}
}
log::info!("Fetching Wayfern version from https://donutbrowser.com/wayfern.json");
let url = "https://donutbrowser.com/wayfern.json";
let mut last_err = None;
@@ -291,7 +312,7 @@ impl ApiClient {
}
}
Err(e) => {
log::warn!("Wayfern fetch attempt {attempt}/3 failed: {e}");
log::debug!("Wayfern version fetch attempt failed attempt={attempt} err=\"{e}\"");
proxy_err = crate::system_proxy::explain(&e);
last_err = Some(e.to_string());
}
@@ -302,19 +323,31 @@ impl ApiClient {
}
}
let version_info = version_info.ok_or_else(|| {
proxy_err.unwrap_or_else(|| {
format!(
"Failed to fetch Wayfern version after 3 attempts: {}",
last_err.unwrap_or_default()
)
})
})?;
log::info!("Fetched Wayfern version: {}", version_info.version);
let version_info = match version_info {
Some(info) => {
WAYFERN_VERSION_FETCH.succeeded();
info
}
None => {
let err = proxy_err.unwrap_or_else(|| {
format!(
"Failed to fetch Wayfern version after 3 attempts: {}",
last_err.unwrap_or_default()
)
});
WAYFERN_VERSION_FETCH.failed(&err);
return Err(err.into());
}
};
if wayfern_version_changed(&version_info.version) {
log::info!("Wayfern version fetched version={}", version_info.version);
} else {
log::debug!("Wayfern version fetched version={}", version_info.version);
}
if !no_caching {
if let Err(e) = self.save_cached_wayfern_version(&version_info) {
log::error!("Failed to cache Wayfern version: {e}");
log::warn!("Wayfern version not cached err=\"{e}\"");
}
}
@@ -368,10 +401,10 @@ impl ApiClient {
let path = entry.path();
if path.is_file() {
fs::remove_file(&path)?;
log::info!("Removed cache file: {path:?}");
log::debug!("Version cache file removed path={path:?}");
}
}
log::info!("All version cache cleared successfully");
log::info!("Version cache cleared");
}
Ok(())
+39 -28
View File
@@ -24,6 +24,11 @@ use tower_http::cors::CorsLayer;
use utoipa::{OpenApi, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes};
/// Refused requests come in bursts from one client; one line per route and
/// status a minute is enough to see who is refused and how often.
static REFUSED_REQUESTS: crate::log_streak::Throttle =
crate::log_streak::Throttle::new(std::time::Duration::from_secs(60));
// API Types
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct ApiProfile {
@@ -887,9 +892,8 @@ impl ApiServer {
"/v1/openapi.json",
get(move || async move { Json(api_for_v1) }),
)
// Outermost layer: logs every request so customer reports show what
// their automation is actually calling, what the response status was,
// and how long it took. Never logs request bodies or auth headers.
// Outermost layer: logs failed requests with route, status and duration.
// Never logs request bodies, query strings or auth headers.
.layer(middleware::from_fn(request_logging_middleware))
.layer(CorsLayer::permissive())
.with_state(state);
@@ -1047,7 +1051,7 @@ async fn auth_middleware(
let token = match auth_header {
Some(token) => token,
None => {
log::warn!("[api] Rejected {path}: missing Authorization header");
log::debug!("API auth refused reason=missing_header path={path}");
return Err(StatusCode::UNAUTHORIZED);
}
};
@@ -1057,13 +1061,11 @@ async fn auth_middleware(
let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
Ok(Some(stored_token)) => stored_token,
Ok(None) => {
log::warn!(
"[api] Rejected {path}: API server has no stored token (was the API toggled off?)"
);
log::debug!("API auth refused reason=no_stored_token path={path}");
return Err(StatusCode::UNAUTHORIZED);
}
Err(e) => {
log::error!("[api] Failed to read stored API token: {e}");
log::error!("API token not readable err=\"{e}\"");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
@@ -1076,7 +1078,7 @@ async fn auth_middleware(
let stored_bytes = stored_token.as_bytes();
let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into();
if !matches {
log::warn!("[api] Rejected {path}: token mismatch");
log::debug!("API auth refused reason=token_mismatch path={path}");
return Err(StatusCode::UNAUTHORIZED);
}
@@ -1084,12 +1086,16 @@ async fn auth_middleware(
Ok(next.run(request).await)
}
/// Logs every request: method, path, query, response status, duration.
/// Logs a failed request with its route, status and duration; a successful
/// one at debug. 401 and 429 are logged by their own middleware.
/// Skips Authorization header and request bodies entirely.
async fn request_logging_middleware(request: axum::extract::Request, next: Next) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let query = request.uri().query().map(|q| q.to_string());
let route = request
.extensions()
.get::<axum::extract::MatchedPath>()
.map(|matched| matched.as_str().to_string())
.unwrap_or_else(|| request.uri().path().to_string());
let started = std::time::Instant::now();
let response = next.run(request).await;
@@ -1097,20 +1103,25 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
let status = response.status();
let elapsed_ms = started.elapsed().as_millis();
if !(status.is_client_error() || status.is_server_error()) {
log::debug!(
"API request method={method} path={route} status={} elapsed_ms={elapsed_ms}",
status.as_u16()
);
return response;
}
let level = if status.is_server_error() {
log::Level::Error
} else if status.is_client_error() {
log::Level::Warn
} else {
log::Level::Info
log::Level::Warn
};
match query {
Some(q) => log::log!(
if let Some(dropped) = REFUSED_REQUESTS.allow(&format!("{method} {route} {}", status.as_u16())) {
log::log!(
level,
"[api] {method} {path}?{q} -> {status} ({elapsed_ms} ms)"
),
None => log::log!(level, "[api] {method} {path} -> {status} ({elapsed_ms} ms)"),
"API request failed method={method} path={route} status={} elapsed_ms={elapsed_ms}{}",
status.as_u16(),
crate::log_streak::suppressed(dropped)
);
}
response
@@ -1192,10 +1203,9 @@ async fn rate_limit_middleware(request: axum::extract::Request, next: Next) -> R
match crate::automation_rate_limiter::check_automation_rate_limit().await {
crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } => {
log::warn!(
"[api] Rejected {}: automation rate limit exceeded; retry in {}s",
request.uri().path(),
retry_after_secs
log::debug!(
"API automation rate limited path={} retry_after_s={retry_after_secs}",
request.uri().path()
);
(
StatusCode::TOO_MANY_REQUESTS,
@@ -1618,8 +1628,9 @@ async fn create_profile(
let _ = crate::events::emit_empty("profile-generation-limit-reached");
}
log::warn!(
"[api] Could not create profile '{}': {body}",
Plain(&request.name)
"API profile create failed status={} err=\"{}\"",
status.as_u16(),
Plain(&body)
);
Err((status, body))
}
@@ -3506,7 +3517,7 @@ async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_ta
() = to_client => {}
}
log::info!(
"CDP proxy for remote session {} closed",
"Remote session CDP proxy closed session={}",
ShortId(&session_id)
);
}
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -146,7 +146,7 @@ fn window_state_override_for(relocated: bool, data_dir: PathBuf) -> Option<PathB
let path = data_dir.join(WINDOW_STATE_FILENAME);
if !path.is_absolute() {
log::warn!(
"Ignoring relative window-state override {}: the plugin resolves its filename against app_config_dir, so geometry would never persist. Set DONUTBROWSER_DATA_DIR/DONUTBROWSER_DATA_ROOT to an absolute path.",
"Relative window-state path ignored: geometry would not persist; set DONUTBROWSER_DATA_DIR or DONUTBROWSER_DATA_ROOT to an absolute path path=\"{}\"",
path.display()
);
return None;
@@ -345,7 +345,10 @@ pub fn restrict_to_owner(path: &std::path::Path) {
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
log::warn!("Failed to restrict permissions on {}: {e}", path.display());
log::warn!(
"Owner-only permission set failed path=\"{}\" err=\"{e}\"",
path.display()
);
}
}
#[cfg(not(unix))]
+121 -113
View File
@@ -78,6 +78,7 @@ impl AutoUpdater {
.push(profile);
}
let mut fetch_failures = Vec::new();
for (browser, profiles) in browser_profiles {
// Always fetch fresh versions for update checks — stale cache would miss new releases
let versions = match self
@@ -87,15 +88,15 @@ impl AutoUpdater {
{
Ok(versions) => versions,
Err(e) => {
log::warn!("Failed to fetch versions for {browser}: {e}, trying cache");
// Fall back to cache if network fails
if let Some(cached) = self
let cached = self
.browser_version_manager
.get_cached_browser_versions_detailed(&browser)
{
cached
} else {
continue;
.get_cached_browser_versions_detailed(&browser);
let fallback = if cached.is_some() { "cache" } else { "none" };
fetch_failures.push(format!("browser={browser} fallback={fallback} {e}"));
match cached {
Some(cached) => cached,
None => continue,
}
}
};
@@ -110,116 +111,112 @@ impl AutoUpdater {
}
}
if fetch_failures.is_empty() {
VERSION_FETCH_STREAK.succeeded();
} else {
VERSION_FETCH_STREAK.failed(fetch_failures.join("; "));
}
Ok(notifications)
}
pub async fn check_for_updates_with_progress(&self, app_handle: &tauri::AppHandle) {
log::info!("Starting auto-update check with progress...");
// Browser auto-updates are always enabled — the disable_auto_updates setting
// only controls app self-updates, not browser version updates.
// Check for browser updates and trigger auto-downloads
let started = std::time::Instant::now();
match self.check_for_updates().await {
Ok(update_notifications) => {
// Group by browser+version to avoid duplicate downloads
let grouped = self.group_update_notifications(update_notifications);
if !grouped.is_empty() {
log::info!("Found {} browser updates", grouped.len());
let summary = grouped
.iter()
.map(|n| {
format!(
"{}@{}(profiles={})",
n.browser,
n.new_version,
n.affected_profiles.len()
)
})
.collect::<Vec<_>>()
.join(",");
let changed = BROWSER_UPDATE_LAST_SUMMARY
.lock()
.map(|mut last| last.replace(summary.clone()) != Some(summary.clone()))
.unwrap_or(true);
log::log!(
if changed {
log::Level::Info
} else {
log::Level::Debug
},
"Browser update check updates=[{summary}] elapsed_ms={}",
started.elapsed().as_millis()
);
for notification in grouped {
let browser = notification.browser.clone();
let new_version = notification.new_version.clone();
let app_handle_clone = app_handle.clone();
for notification in grouped {
log::info!(
"Auto-updating {} to version {} ({} profiles)",
notification.browser,
notification.new_version,
notification.affected_profiles.len()
);
// Spawn async task to handle the download and auto-update
tokio::spawn(async move {
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
let browser = notification.browser.clone();
let new_version = notification.new_version.clone();
let app_handle_clone = app_handle.clone();
// Skip if this browser-version pair is already being downloaded
if crate::downloader::is_downloading(&browser, &new_version) {
log::debug!(
"Browser update download already running browser={browser} version={new_version}"
);
return;
}
// Spawn async task to handle the download and auto-update
tokio::spawn(async move {
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
// Skip if this browser-version pair is already being downloaded
if crate::downloader::is_downloading(&browser, &new_version) {
log::info!(
"Browser {browser} {new_version} is already being downloaded, skipping duplicate"
);
return;
}
if registry.is_browser_downloaded(&browser, &new_version) {
log::info!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
// Browser already exists, go straight to profile update
match AutoUpdater::instance()
.auto_update_profile_versions(&app_handle_clone, &browser, &new_version)
.await
{
Ok(updated_profiles) => {
if !updated_profiles.is_empty() {
log::info!(
"Auto-updated {} profiles to {browser} {new_version}: {:?}",
updated_profiles.len(),
updated_profiles
);
}
}
Err(e) => {
log::error!("Failed to auto-update profiles for {browser}: {e}");
}
}
} else {
log::info!("Downloading browser {browser} version {new_version}...");
// Download directly from Rust — download_browser_full already
// auto-updates non-running profiles after successful download.
match crate::downloader::download_browser(
app_handle_clone,
browser.clone(),
new_version.clone(),
)
if registry.is_browser_downloaded(&browser, &new_version) {
// Browser already exists, go straight to profile update
if let Err(e) = AutoUpdater::instance()
.auto_update_profile_versions(&app_handle_clone, &browser, &new_version)
.await
{
Ok(actual_version) => {
log::info!("Auto-download completed for {browser} {actual_version}");
}
Err(e) => {
log::error!("Failed to auto-download {browser} {new_version}: {e}");
}
}
{
log::error!(
"Browser update apply failed browser={browser} version={new_version} err=\"{e}\""
);
}
});
}
} else {
log::info!("No browser updates needed");
} else {
// Download directly from Rust — download_browser_full already
// auto-updates non-running profiles after successful download.
let download_started = std::time::Instant::now();
// download_browser logs its own failure.
if let Ok(actual_version) = crate::downloader::download_browser(
app_handle_clone,
browser.clone(),
new_version.clone(),
)
.await
{
log::info!(
"Browser update downloaded browser={browser} version={actual_version} elapsed_ms={}",
download_started.elapsed().as_millis()
);
}
}
});
}
}
Err(e) => {
log::error!("Failed to check for browser updates: {e}");
log::error!(
"Browser update check failed elapsed_ms={} err=\"{e}\"",
started.elapsed().as_millis()
);
}
}
// Also update any profiles that can be bumped to an already-installed newer version.
// This handles cases where a version was downloaded but profiles weren't updated
// (e.g., they were running at the time, or the update was missed).
match self.update_profiles_to_latest_installed(app_handle) {
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Updated {} profiles to latest installed versions: {:?}",
updated.len(),
updated
);
}
}
Err(e) => {
log::error!("Failed to update profiles to latest installed versions: {e}");
}
if let Err(e) = self.update_profiles_to_latest_installed(app_handle) {
log::error!("Moving profiles to the latest installed browser failed err=\"{e}\"");
}
}
@@ -314,21 +311,14 @@ impl AutoUpdater {
// moment it closes, and would pin that older binary against cleanup.
if !self.is_version_newer(new_version, &profile.version) {
log::debug!(
"Not queuing {} for running profile {}: not newer than {}",
new_version,
profile.name,
"Pending browser update skipped: not newer profile={} from={} to={new_version}",
profile.id,
profile.version
);
continue;
}
// Store as pending update so it gets applied when browser closes
log::info!(
"Profile {} is running, storing pending update {} -> {}",
profile.name,
profile.version,
new_version
);
let mut state = self.load_auto_update_state().unwrap_or_default();
let notification = UpdateNotification {
id: format!("{}_{}_to_{}", browser, profile.version, new_version),
@@ -349,6 +339,11 @@ impl AutoUpdater {
{
state.pending_updates.push(notification);
let _ = self.save_auto_update_state(&state);
log::info!(
"Browser update queued until the profile closes profile={} browser={browser} from={} to={new_version}",
profile.id,
profile.version
);
}
continue;
}
@@ -362,10 +357,18 @@ impl AutoUpdater {
new_version,
) {
Ok(_) => {
log::info!(
"Profile browser updated profile={} browser={browser} from={} to={new_version}",
profile.id,
profile.version
);
updated_profiles.push(profile.name);
}
Err(e) => {
log::error!("Failed to update profile {}: {}", profile.name, e);
log::error!(
"Profile browser update failed profile={} browser={browser} to={new_version} err=\"{e}\"",
profile.id
);
}
}
}
@@ -513,18 +516,18 @@ impl AutoUpdater {
{
Ok(updated) => {
log::info!(
"Updated profile {} from {} {} to latest installed version {}",
profile.name,
"Profile browser updated profile={} browser={} from={} to={latest}",
profile.id,
profile.browser,
profile.version,
latest
profile.version
);
Some(updated)
}
Err(e) => {
log::error!(
"Failed to update profile {} to latest installed version: {e}",
profile.name
"Profile browser update failed profile={} browser={} to={latest} err=\"{e}\"",
profile.id,
profile.browser
);
None
}
@@ -590,16 +593,17 @@ impl AutoUpdater {
) {
Ok(_) => {
log::info!(
"Updated profile {} from {} {} to latest installed version {}",
profile.name,
browser,
profile.version,
latest_version
"Profile browser updated profile={} browser={browser} from={} to={latest_version}",
profile.id,
profile.version
);
all_updated.push(profile.name);
}
Err(e) => {
log::error!("Failed to update profile {}: {e}", profile.name);
log::error!(
"Profile browser update failed profile={} browser={browser} to={latest_version} err=\"{e}\"",
profile.id
);
}
}
}
@@ -651,6 +655,10 @@ pub async fn check_for_updates_with_progress(app_handle: tauri::AppHandle) {
// Global singleton instance
static AUTO_UPDATER: std::sync::LazyLock<AutoUpdater> = std::sync::LazyLock::new(AutoUpdater::new);
static VERSION_FETCH_STREAK: crate::log_streak::Streak =
crate::log_streak::Streak::new(module_path!(), "Browser version fetch");
/// Updates found by the previous check, so an unchanged result logs at debug.
static BROWSER_UPDATE_LAST_SUMMARY: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
#[cfg(test)]
mod tests {
+83 -106
View File
@@ -15,12 +15,12 @@ fn set_high_priority() {
// This may fail without elevated privileges, which is fine
let result = libc::setpriority(libc::PRIO_PROCESS, 0, -10);
if result == 0 {
log::info!("Set process priority to -10 (high priority)");
log::debug!("Process priority set nice=-10");
} else {
// Try a less aggressive priority if -10 fails
let result = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
if result == 0 {
log::info!("Set process priority to -5 (above normal)");
log::debug!("Process priority set nice=-5");
}
}
}
@@ -32,9 +32,9 @@ fn set_high_priority() {
// Valid range is -1000 to 1000, lower = less likely to be killed
// -500 is a reasonable value that makes us less likely to be killed
if let Err(e) = std::fs::write("/proc/self/oom_score_adj", "-500") {
log::debug!("Could not set OOM score adjustment: {}", e);
log::debug!("OOM score adjustment failed err=\"{e}\"");
} else {
log::info!("Set OOM score adjustment to -500");
log::debug!("OOM score adjustment set oom_score_adj=-500");
}
}
@@ -47,9 +47,9 @@ fn set_high_priority() {
unsafe {
let process = GetCurrentProcess();
if SetPriorityClass(process, ABOVE_NORMAL_PRIORITY_CLASS).is_ok() {
log::info!("Set process priority to ABOVE_NORMAL_PRIORITY_CLASS");
log::debug!("Process priority set class=ABOVE_NORMAL");
} else {
log::debug!("Could not set process priority class");
log::debug!("Process priority class set failed");
}
}
}
@@ -62,26 +62,43 @@ async fn main() {
// Default filter is Info — Debug pulls in reqwest/hyper internals which
// make the per-worker log unreadable on a busy browser session and obscure
// the actual lines we care about (binds, accept errors, upstream failures).
// RUST_LOG=debug or RUST_LOG=donut_proxy=trace still works for deep dives.
env_logger::Builder::from_default_env()
// RUST_LOG is parsed after the default so it wins: `debug`, or
// `donutbrowser_lib::proxy_server=trace` for the tunnel code alone.
// Same line shape as the app log: `[ts][target][LEVEL] message`, with the
// crate prefix dropped from the target.
env_logger::Builder::new()
.filter_level(log::LevelFilter::Info)
.format_timestamp_millis()
.parse_default_env()
.format(|buf, record| {
use std::io::Write;
let target = record.target();
let target = target.strip_prefix("donutbrowser_lib::").unwrap_or(target);
writeln!(
buf,
"[{}][{}][{}] {}",
buf.timestamp_millis(),
target,
record.level(),
record.args()
)
})
.init();
// Set up panic handler to log panics before process exits
std::panic::set_hook(Box::new(|panic_info| {
log::error!("PANIC in proxy worker: {:?}", panic_info);
if let Some(location) = panic_info.location() {
log::error!(
"Location: {}:{}:{}",
location.file(),
location.line(),
location.column()
);
}
if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
log::error!("Message: {}", s);
}
let location = panic_info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "-".to_string());
let message = panic_info
.payload()
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
.unwrap_or_default();
log::error!(
"Worker panicked pid={} at={location} msg={message:?}",
std::process::id()
);
}));
let matches = Command::new("donut-proxy")
@@ -373,7 +390,7 @@ async fn main() {
println!("{}", serde_json::to_string(&configs).unwrap());
process::exit(0);
} else {
log::error!("Invalid action. Use 'start', 'stop', or 'list'");
eprintln!("Invalid action. Use 'start', 'stop', or 'list'");
process::exit(1);
}
} else if let Some(xray_matches) = matches.subcommand_matches("xray") {
@@ -459,7 +476,7 @@ async fn main() {
}
}
} else {
log::error!("Invalid action. Use 'start', 'bind', or 'stop'");
eprintln!("Invalid action. Use 'start', 'bind', or 'stop'");
process::exit(1);
}
} else if let Some(worker_matches) = matches.subcommand_matches("proxy-worker") {
@@ -474,51 +491,42 @@ async fn main() {
// Set high priority so this process is killed last under resource pressure
set_high_priority();
log::info!(
"Proxy worker starting (pid {}, config id {})",
std::process::id(),
id
);
// Retry config loading to handle file system race condition on Windows
// where the config file may not be immediately visible after being written
let config = {
let mut attempts = 0;
loop {
if let Some(config) = get_proxy_config(id) {
log::info!(
"Found config: id={}, port={:?}, upstream={}",
config.id,
config.local_port,
redacted_upstream(&config.upstream_url)
);
break config;
}
attempts += 1;
if attempts >= 10 {
log::error!(
"Proxy configuration {} not found after {} attempts",
id,
attempts
"Worker exiting kind=proxy id={id} pid={} reason=config_not_found attempts={attempts}",
process::id()
);
process::exit(1);
}
log::debug!("Config {} not found yet, retrying ({}/10)...", id, attempts);
std::thread::sleep(std::time::Duration::from_millis(50));
}
};
// Run the proxy server - this should never return (infinite loop)
log::info!("Starting proxy server for config id: {}", id);
if let Err(e) = run_proxy_server(config).await {
log::error!("Proxy server failed: {} ({:?})", e, e);
log::error!(
"Worker exiting kind=proxy id={id} pid={} reason=error err=\"{e}\"",
process::id()
);
process::exit(1);
}
// This should never be reached - run_proxy_server has an infinite loop
log::error!("Proxy server returned unexpectedly (this should never happen)");
log::error!(
"Worker exiting kind=proxy id={id} pid={} reason=accept_loop_returned",
process::id()
);
process::exit(1);
} else {
log::error!("Invalid action for proxy-worker. Use 'start'");
eprintln!("Invalid action for proxy-worker. Use 'start'");
process::exit(1);
}
} else if let Some(vpn_matches) = matches.subcommand_matches("vpn-worker") {
@@ -533,67 +541,41 @@ async fn main() {
if action == "start" {
set_high_priority();
log::info!("VPN worker starting, config id: {}", id);
log::info!("Process PID: {}", std::process::id());
let pid = process::id();
let exit_with = |reason: &str, detail: String| -> ! {
log::error!("Worker exiting kind=vpn id={id} pid={pid} reason={reason} {detail}");
process::exit(1);
};
let config = if let Some(path) = config_path {
// Load config directly from the provided path
log::info!("Loading VPN worker config from: {}", path);
match std::fs::read_to_string(path) {
Ok(content) => match serde_json::from_str::<
donutbrowser_lib::vpn_worker_storage::VpnWorkerConfig,
>(&content)
{
Ok(config) => {
log::info!(
"Found VPN worker config: id={}, vpn_type={}, vpn_id={}",
config.id,
config.vpn_type,
config.vpn_id
);
config
}
Err(e) => {
log::error!("Failed to parse VPN worker config from {}: {}", path, e);
process::exit(1);
}
Ok(config) => config,
Err(e) => exit_with("config_unparsable", format!("path=\"{path}\" err=\"{e}\"")),
},
Err(e) => {
log::error!("Failed to read VPN worker config from {}: {}", path, e);
process::exit(1);
}
Err(e) => exit_with("config_unreadable", format!("path=\"{path}\" err=\"{e}\"")),
}
} else {
// Fallback: discover config by ID with retries
let storage_dir = donutbrowser_lib::proxy_storage::get_storage_dir();
log::info!("Looking for VPN worker config in: {:?}", storage_dir);
let mut attempts = 0;
loop {
if let Some(config) = donutbrowser_lib::vpn_worker_storage::get_vpn_worker_config(id) {
log::info!(
"Found VPN worker config: id={}, vpn_type={}, vpn_id={}",
config.id,
config.vpn_type,
config.vpn_id
);
break config;
}
attempts += 1;
if attempts >= 50 {
log::error!(
"VPN worker configuration {} not found after {} attempts in {:?}",
id,
attempts,
storage_dir
exit_with(
"config_not_found",
format!(
"attempts={attempts} dir=\"{}\"",
donutbrowser_lib::proxy_storage::get_storage_dir().display()
),
);
process::exit(1);
}
log::info!(
"VPN worker config {} not found yet, retrying ({}/50)...",
id,
attempts
);
std::thread::sleep(std::time::Duration::from_millis(100));
}
};
@@ -601,24 +583,20 @@ async fn main() {
// Read the decrypted VPN config from the temp file
let vpn_config_data = match std::fs::read_to_string(&config.config_file_path) {
Ok(data) => data,
Err(e) => {
log::error!(
"Failed to read VPN config file {}: {}",
config.config_file_path,
e
);
process::exit(1);
}
Err(e) => exit_with(
"vpn_config_unreadable",
format!("vpn={} err=\"{e}\"", config.vpn_id),
),
};
match config.vpn_type.as_str() {
"wireguard" => {
let wg_config = match donutbrowser_lib::vpn::parse_wireguard_config(&vpn_config_data) {
Ok(c) => c,
Err(e) => {
log::error!("Failed to parse WireGuard config: {}", e);
process::exit(1);
}
Err(e) => exit_with(
"wireguard_config_invalid",
format!("vpn={} err=\"{e}\"", config.vpn_id),
),
};
let server =
@@ -627,17 +605,13 @@ async fn main() {
.run(id.clone(), config_path.map(std::path::PathBuf::from))
.await
{
log::error!("VPN worker failed: {}", e);
process::exit(1);
exit_with("error", format!("vpn={} err=\"{e}\"", config.vpn_id));
}
}
other => {
log::error!("Unknown VPN type: {}", other);
process::exit(1);
}
other => exit_with("unknown_vpn_type", format!("vpn_type={other}")),
}
} else {
log::error!("Invalid action for vpn-worker. Use 'start'");
eprintln!("Invalid action for vpn-worker. Use 'start'");
process::exit(1);
}
} else if let Some(xray_matches) = matches.subcommand_matches("xray-worker") {
@@ -648,7 +622,7 @@ async fn main() {
.get_one::<String>("config-path")
.expect("config-path is required");
if action != "start" {
log::error!("Invalid action for xray-worker. Use 'start'");
eprintln!("Invalid action for xray-worker. Use 'start'");
process::exit(1);
}
@@ -656,11 +630,14 @@ async fn main() {
if let Err(error) =
donutbrowser_lib::xray_worker_runner::run_xray_worker(std::path::Path::new(config_path)).await
{
log::error!("Xray worker failed: {error}");
log::error!(
"Worker exiting kind=xray pid={} reason=error err=\"{error}\"",
process::id()
);
process::exit(1);
}
} else {
log::error!("No command specified");
eprintln!("No command specified");
process::exit(1);
}
}
+1 -5
View File
@@ -158,7 +158,6 @@ mod linux {
#[allow(dead_code)]
pub fn prepare_executable(executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Linux, ensure the executable has proper permissions
log::info!("Setting execute permissions for: {:?}", executable_path);
let metadata = std::fs::metadata(executable_path)?;
let mut permissions = metadata.permissions();
@@ -169,10 +168,7 @@ mod linux {
std::fs::set_permissions(executable_path, permissions)?;
log::info!(
"Execute permissions set successfully for: {:?}",
executable_path
);
log::debug!("Execute permission set path={}", executable_path.display());
Ok(())
}
}
File diff suppressed because it is too large. Load diff
+17 -6
View File
@@ -204,7 +204,7 @@ impl BrowserVersionManager {
.api_client
.save_cached_versions(browser, &merged_releases)
{
log::error!("Failed to save merged cache for {browser}: {e}");
log::warn!("Browser version cache save failed browser={browser} err=\"{e}\"");
}
}
@@ -282,7 +282,7 @@ impl BrowserVersionManager {
})
.collect();
if let Err(e) = self.api_client.save_cached_versions(browser, &releases) {
log::error!("Failed to save updated cache for {browser}: {e}");
log::warn!("Browser version cache save failed browser={browser} err=\"{e}\"");
}
Ok(new_versions_count)
@@ -486,11 +486,15 @@ pub async fn fetch_browser_versions_cached_first(
let service_clone = BrowserVersionManager::instance();
let browser_str_clone = browser_str.clone();
tokio::spawn(async move {
if let Err(e) = service_clone
match service_clone
.fetch_browser_versions_detailed(&browser_str_clone, false)
.await
{
log::error!("Background version update failed for {browser_str_clone}: {e}");
Ok(_) => BACKGROUND_UPDATE.succeeded(),
Err(e) => BACKGROUND_UPDATE.failed(format_args!(
"browser={browser_str_clone} {}",
crate::log_redaction::text(&e.to_string())
)),
}
});
}
@@ -518,11 +522,15 @@ pub async fn fetch_browser_versions_with_count_cached_first(
let service_clone = BrowserVersionManager::instance();
let browser_str_clone = browser_str.clone();
tokio::spawn(async move {
if let Err(e) = service_clone
match service_clone
.fetch_browser_versions_with_count(&browser_str_clone, false)
.await
{
log::error!("Background version update failed for {browser_str_clone}: {e}");
Ok(_) => BACKGROUND_UPDATE.succeeded(),
Err(e) => BACKGROUND_UPDATE.failed(format_args!(
"browser={browser_str_clone} {}",
crate::log_redaction::text(&e.to_string())
)),
}
});
}
@@ -554,5 +562,8 @@ pub async fn fetch_browser_versions_with_count(
}
// Global singleton instance
static BACKGROUND_UPDATE: crate::log_streak::Streak =
crate::log_streak::Streak::new(module_path!(), "Background browser version update");
static BROWSER_VERSION_SERVICE: std::sync::LazyLock<BrowserVersionManager> =
std::sync::LazyLock::new(BrowserVersionManager::new);
+9 -9
View File
@@ -210,9 +210,9 @@ pub async fn resolve(profile: &BrowserProfile) -> Result<CdpTarget, ResolveError
.await
.map_err(|e| ResolveError::Endpoint(e.to_string()))?;
let bearer = crate::remote_session::access_token_for_cdp().map_err(ResolveError::Endpoint)?;
log::info!(
"Driving profile '{}' through remote session {}",
profile.name,
log::debug!(
"Driving profile through remote session profile={} session={}",
profile.id,
ShortId(&session.session_id)
);
return Ok(CdpTarget::Remote {
@@ -295,7 +295,7 @@ async fn local_page_ws_url(profile: &BrowserProfile, patience: Patience) -> Opti
}
if patience == Patience::WaitForLaunch {
log::warn!("Local CDP discovery on port {port} gave up: {last_err}");
log::warn!("Local CDP discovery gave up cdp_port={port} err=\"{last_err}\"");
}
None
}
@@ -696,8 +696,8 @@ impl CdpTarget {
return Err(error);
}
let delay = CONNECT_RETRY_BASE * 2u32.pow(attempt - 1);
log::warn!(
"CDP connect to {} failed ({error}); retrying in {}ms",
log::debug!(
"CDP connect failed target=\"{}\" attempt={attempt} retry_in_ms={} err=\"{error}\"",
self.describe(),
delay.as_millis()
);
@@ -722,7 +722,7 @@ impl CdpTarget {
let mut connection = dial_relay(ws_url, bearer).await?;
if let Err(e) = connection.attach_to_page().await {
log::warn!(
"Could not attach to a page in remote session {}: {e}",
"Remote session page attach failed session={} err=\"{e}\"",
ShortId(session_id)
);
return Err(e);
@@ -763,7 +763,7 @@ pub async fn open_relay_socket(session_id: &str) -> Result<RelaySocket, CdpError
Err(e) => return Err(e),
};
log::info!("The CDP relay refused the stored access token; refreshing and retrying once");
log::debug!("CDP relay refused the access token, refreshing it once");
crate::cloud_auth::CLOUD_AUTH
.refresh_access_token()
.await
@@ -824,7 +824,7 @@ async fn dial_relay(ws_url: &str, bearer: &str) -> Result<CdpConnection, CdpErro
Err(e) => return Err(e),
};
log::info!("The CDP relay refused the stored access token; refreshing and retrying once");
log::debug!("CDP relay refused the access token, refreshing it once");
crate::cloud_auth::CLOUD_AUTH
.refresh_access_token()
.await
+112 -92
View File
@@ -8,6 +8,7 @@ use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use crate::browser::ProxySettings;
use crate::log_streak::Streak;
use crate::proxy_manager::PROXY_MANAGER;
use crate::settings_manager::{SettingsManager, StoredMcpRemoteKey};
use crate::sync;
@@ -24,6 +25,28 @@ const SYNC_TOKEN_REFRESH_MARGIN_SECS: i64 = 120;
const DEFAULT_RETRY_AFTER: Duration = Duration::from_secs(60);
const MAX_RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
static ACCESS_TOKEN_REFRESH: Streak = Streak::new(module_path!(), "Access token refresh");
static SYNC_TOKEN_FETCH: Streak = Streak::new(module_path!(), "Sync token fetch");
static PROFILE_REFRESH: Streak = Streak::new(module_path!(), "Cloud profile refresh");
static PROXY_CONFIG_FETCH: Streak = Streak::new(module_path!(), "Cloud proxy config fetch");
static PROXY_USAGE_FETCH: Streak = Streak::new(module_path!(), "Cloud proxy usage fetch");
static WAYFERN_TOKEN: Streak = Streak::new(module_path!(), "Wayfern token request");
/// What the last cloud proxy sync found, so an unchanged result is not
/// logged every ten minutes.
static CLOUD_PROXY_STATE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
fn cloud_proxy_state_changed(state: String) -> bool {
let mut last = CLOUD_PROXY_STATE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if last.as_deref() == Some(state.as_str()) {
return false;
}
*last = Some(state);
true
}
pub(crate) fn parse_retry_after(value: &str, now: chrono::DateTime<Utc>) -> Option<Duration> {
let value = value.trim();
if let Ok(seconds) = value.parse::<u64>() {
@@ -618,34 +641,24 @@ impl CloudAuthManager {
.map_err(|e| format!("Failed to parse response: {e}"))?;
// Store tokens
log::info!(
"Storing access token (len={}) and refresh token (len={})",
result.access_token.len(),
result.refresh_token.len()
);
Self::store_access_token(&result.access_token)?;
Self::store_refresh_token(&result.refresh_token)?;
// Verify tokens survived the encrypt/decrypt round-trip
match Self::load_access_token() {
Ok(Some(loaded)) if loaded == result.access_token => {
log::info!(
"Access token verified after store/load (len={})",
loaded.len()
);
}
Ok(Some(loaded)) if loaded == result.access_token => {}
Ok(Some(loaded)) => {
log::error!(
"Access token CORRUPTED during store/load: original_len={}, loaded_len={}",
"Access token corrupted by store and load stored_len={} loaded_len={}",
result.access_token.len(),
loaded.len()
);
}
Ok(None) => {
log::error!("Access token missing immediately after store");
log::error!("Access token missing right after store");
}
Err(e) => {
log::error!("Failed to load access token for verification: {e}");
log::error!("Access token not readable after store err=\"{e}\"");
}
}
@@ -657,7 +670,7 @@ impl CloudAuthManager {
Self::store_auth_state(&auth_state)?;
log::info!(
"Login successful: plan={}, subscription_status={}, proxy_bandwidth_limit={}MB",
"Signed in plan={} subscription_status={} proxy_bandwidth_limit_mb={}",
auth_state.user.plan,
auth_state.user.subscription_status,
auth_state.user.proxy_bandwidth_limit_mb
@@ -682,8 +695,6 @@ impl CloudAuthManager {
wait.as_secs()
));
}
log::info!("Refreshing access token (holding lock)...");
let refresh_token =
Self::load_refresh_token()?.ok_or_else(|| "No refresh token stored".to_string())?;
@@ -698,7 +709,7 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
log::warn!("Token refresh failed ({status})");
log::debug!("Access token refresh refused status={status}");
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
self.defer_refresh(retry_after(response.headers()));
} else if status == reqwest::StatusCode::UNAUTHORIZED {
@@ -718,7 +729,7 @@ impl CloudAuthManager {
Self::store_access_token(&result.access_token)?;
Self::store_refresh_token(&result.refresh_token)?;
log::info!("Access token refreshed successfully");
log::debug!("Access token refreshed");
Ok(())
}
@@ -767,7 +778,7 @@ impl CloudAuthManager {
Ok(fresh) => Ok(fresh),
Err(e) if e.contains("(401") => Err(e),
Err(e) => {
log::debug!("Early access token refresh did not complete: {e}");
log::debug!("Early access token refresh not done err=\"{e}\"");
Ok(token)
}
}
@@ -777,7 +788,7 @@ impl CloudAuthManager {
let _guard = self.refresh_lock.lock().await;
if let Some(current) = Self::load_access_token()? {
if current != rejected && !current.is_empty() {
log::info!("Token was already refreshed by another caller, retrying...");
log::debug!("Access token already refreshed by another caller");
return Ok(current);
}
}
@@ -822,7 +833,7 @@ impl CloudAuthManager {
/// Only call this when the session is definitively dead (explicit logout
/// or repeated background refresh failures).
pub async fn invalidate_session(&self) {
log::warn!("Invalidating session — clearing all auth state");
log::warn!("Session invalidated, auth state cleared");
PROXY_MANAGER.remove_cloud_proxy();
// Same reason `logout` does it: left running, the bridge reconnects with a
// credential that no longer exists, fails, and backs off into an
@@ -1001,18 +1012,16 @@ impl CloudAuthManager {
Ok(Some(StoredMcpRemoteKey { id: Some(id), .. })) => {
if let Err(e) = self.revoke_mcp_key(&id).await {
log::warn!(
"Could not revoke the remote MCP credential on logout; revoke it from the account page: {e}"
"Remote MCP key not revoked on logout, revoke it on the account page err=\"{e}\""
);
}
}
Ok(Some(StoredMcpRemoteKey { id: None, .. })) => {
log::warn!(
"The remote MCP credential has no stored id, so it cannot be revoked from here; revoke it from the account page"
);
log::warn!("Remote MCP key has no stored id, revoke it on the account page");
}
Ok(None) => return,
Err(e) => {
log::warn!("Could not read the remote MCP credential on logout: {e}");
log::warn!("Remote MCP key not readable on logout err=\"{e}\"");
}
}
Self::forget_mcp_key_locally("on logout");
@@ -1024,7 +1033,7 @@ impl CloudAuthManager {
/// credential is leaving with the session either way.
fn forget_mcp_key_locally(when: &str) {
if let Err(e) = SettingsManager::instance().remove_mcp_remote_key() {
log::warn!("Could not forget the remote MCP credential {when}: {e}");
log::warn!("Remote MCP key not removed locally when=\"{when}\" err=\"{e}\"");
}
}
@@ -1078,7 +1087,7 @@ impl CloudAuthManager {
}
// Fetch new sync token
let sync_token = self
let fetched = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/sync-token");
let client = self.client.clone();
@@ -1104,9 +1113,22 @@ impl CloudAuthManager {
Ok(result.sync_token)
}
})
.await?;
.await;
let sync_token = match fetched {
Ok(token) => {
SYNC_TOKEN_FETCH.succeeded();
token
}
Err(e) => {
SYNC_TOKEN_FETCH.failed(&e);
return Err(e);
}
};
Self::store_cloud_sync_token(&sync_token)?;
if let Err(e) = Self::store_cloud_sync_token(&sync_token) {
log::error!("Sync token store failed err=\"{e}\"");
return Err(e);
}
Ok(Some(sync_token))
}
@@ -1338,17 +1360,15 @@ impl CloudAuthManager {
match make_request(access_token.clone()).await {
Ok(result) => Ok(result),
Err(e) if e.contains("(401") || e.contains("Unauthorized") => {
log::info!("Got 401/Unauthorized response, attempting token refresh...");
log::debug!("Cloud API returned 401, refreshing the access token");
// Check if another caller already refreshed while we waited
let current_token = Self::load_access_token()?.unwrap_or_default();
if current_token != access_token && !current_token.is_empty() {
log::info!("Token was already refreshed by another caller, retrying...");
return make_request(current_token).await;
}
let new_token = self.refresh_after_rejection(&access_token).await?;
log::info!("Token refreshed, retrying request...");
make_request(new_token).await
}
Err(e) => Err(e),
@@ -1381,7 +1401,6 @@ impl CloudAuthManager {
let status = response.status();
if status == reqwest::StatusCode::FORBIDDEN {
log::warn!("Proxy config returned 403");
return Err("__403__".to_string());
}
@@ -1397,10 +1416,16 @@ impl CloudAuthManager {
})
.await
{
Ok(config) => Ok(Some(config)),
Err(e) if e.contains("__403__") => Ok(None),
Ok(config) => {
PROXY_CONFIG_FETCH.succeeded();
Ok(Some(config))
}
Err(e) if e.contains("__403__") => {
PROXY_CONFIG_FETCH.succeeded();
Ok(None)
}
Err(e) => {
log::warn!("Failed to fetch cloud proxy config: {e}");
PROXY_CONFIG_FETCH.failed(e);
Ok(None)
}
}
@@ -1408,15 +1433,15 @@ impl CloudAuthManager {
/// Sync the cloud-managed proxy: fetch config and upsert or remove
pub async fn sync_cloud_proxy(&self) {
log::info!("Syncing cloud proxy configuration...");
match self.fetch_proxy_config().await {
Ok(Some(config)) => {
log::info!(
"Cloud proxy config received: host={}, port={}, protocol={}",
config.host,
config.port,
config.protocol
let state = format!(
"host={} port={} protocol={}",
config.host, config.port, config.protocol
);
if cloud_proxy_state_changed(state.clone()) {
log::info!("Cloud proxy configured {state}");
}
let settings = ProxySettings {
proxy_type: config.protocol,
host: config.host,
@@ -1427,19 +1452,20 @@ impl CloudAuthManager {
};
match PROXY_MANAGER.upsert_cloud_proxy(settings) {
Ok(_) => {
log::info!("Cloud proxy synced successfully");
// Propagate credential changes to derived location proxies
PROXY_MANAGER.update_cloud_derived_proxies();
}
Err(e) => log::warn!("Failed to upsert cloud proxy: {e}"),
Err(e) => log::warn!("Cloud proxy not saved err=\"{e}\""),
}
}
Ok(None) => {
log::info!("No cloud proxy config available (user may not have proxy bandwidth)");
if cloud_proxy_state_changed("none".to_string()) {
log::info!("Cloud proxy removed: no config available");
}
PROXY_MANAGER.remove_cloud_proxy();
}
Err(e) => {
log::error!("Failed to sync cloud proxy: {e}");
log::error!("Cloud proxy sync failed err=\"{e}\"");
}
}
}
@@ -1511,7 +1537,7 @@ impl CloudAuthManager {
if !self.is_entitled_to_wayfern_token().await {
// Ok(()) here means callers log nothing, so a session that declined to
// mint left no trace at all and looked identical to one that succeeded.
log::info!("Skipping wayfern token request: the cached plan is not active");
log::info!("Wayfern token not requested: plan not active");
self.clear_wayfern_token().await;
return Ok(());
}
@@ -1561,10 +1587,10 @@ impl CloudAuthManager {
// A 403 rejects the entitlement without invalidating the login session.
// Clear the browser token and refresh account state before notifying UI.
if e.contains("(403") || e.contains("Forbidden") {
log::warn!("Wayfern token blocked by backend (403): {e}");
WAYFERN_TOKEN.failed(format!("refused: {}", refusal_message(&e)));
self.clear_wayfern_token().await;
if let Err(fetch_err) = self.fetch_profile().await {
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
PROFILE_REFRESH.failed(fetch_err);
}
// Only the device rules produce a restriction the user can lift, and
// the toast tells them to sign other devices out — so only those may
@@ -1577,6 +1603,8 @@ impl CloudAuthManager {
self.set_wayfern_device_refusal(Some(refusal_message(&e)));
let _ = crate::events::emit_empty("wayfern-paid-blocked");
}
} else {
WAYFERN_TOKEN.failed(&e);
}
return Err(e);
}
@@ -1585,6 +1613,7 @@ impl CloudAuthManager {
let mut wt = self.wayfern_token.lock().await;
*wt = Some(token);
self.set_wayfern_device_refusal(None);
WAYFERN_TOKEN.succeeded();
log::info!("Wayfern token acquired");
Ok(())
}
@@ -1641,33 +1670,30 @@ impl CloudAuthManager {
// This runs first so subsequent API calls use a fresh token.
if let Ok(Some(token)) = Self::load_access_token() {
if Self::access_token_needs_refresh(&token) {
if let Err(e) = CLOUD_AUTH.ensure_fresh_access_token().await {
log::warn!("Failed to refresh cloud access token: {e}");
// If the refresh token itself was rejected, session is irrecoverable
if e.contains("(401") || e.contains("Unauthorized") {
continue;
match CLOUD_AUTH.ensure_fresh_access_token().await {
Ok(_) => ACCESS_TOKEN_REFRESH.succeeded(),
Err(e) => {
ACCESS_TOKEN_REFRESH.failed(&e);
// If the refresh token itself was rejected, session is irrecoverable
if e.contains("(401") || e.contains("Unauthorized") {
continue;
}
}
}
}
}
match CLOUD_AUTH.get_or_refresh_sync_token().await {
Ok(Some(_)) => {
log::debug!("Cloud sync token refreshed successfully");
}
Ok(None) => {}
Err(e) => {
log::warn!("Failed to refresh cloud sync token: {e}");
}
}
// Failures are logged inside.
let _ = CLOUD_AUTH.get_or_refresh_sync_token().await;
let plan_before = CLOUD_AUTH.plan_marker().await;
// Refresh profile data periodically. A failure here leaves the cached
// plan stale, which silently gates paid features, so it belongs at warn
// rather than debug where the shipped log level hides it.
if let Err(e) = CLOUD_AUTH.fetch_profile().await {
log::warn!("Failed to refresh cloud profile: {e}");
match CLOUD_AUTH.fetch_profile().await {
Ok(_) => PROFILE_REFRESH.succeeded(),
Err(e) => PROFILE_REFRESH.failed(e),
}
if CLOUD_AUTH.plan_marker().await != plan_before {
@@ -1705,9 +1731,7 @@ impl CloudAuthManager {
&& CLOUD_AUTH.get_wayfern_token().await.is_none();
if wayfern_refresh_counter >= 60 || missing_entitled_token {
wayfern_refresh_counter = 0;
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to refresh wayfern token: {e}");
}
let _ = CLOUD_AUTH.request_wayfern_token().await;
}
let _ = &app_handle; // keep app_handle alive
@@ -1794,12 +1818,12 @@ pub async fn cloud_exchange_device_code(
// effort: a failure leaves the login response in place.
match CLOUD_AUTH.fetch_profile().await {
Ok(user) => state.user = user,
Err(e) => log::warn!("Post-login profile refresh failed: {e}"),
Err(e) => log::warn!("Post-login profile refresh failed err=\"{e}\""),
}
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
log::info!(
"Post-login: plan={}, effective_plan={}, has_active_subscription={}",
"Post-login plan resolved plan={} effective_plan={} has_active_subscription={}",
state.user.plan,
state.user.effective_plan(),
has_subscription
@@ -1807,17 +1831,13 @@ pub async fn cloud_exchange_device_code(
// Pre-fetch sync token so sync can start immediately
if has_subscription {
log::info!("Pre-fetching sync token...");
match CLOUD_AUTH.get_or_refresh_sync_token().await {
Ok(Some(_)) => log::info!("Sync token pre-fetched successfully"),
Ok(None) => log::warn!("Sync token not available despite active subscription"),
Err(e) => log::error!("Failed to pre-fetch sync token after login: {e}"),
// Failures of both are logged inside.
if let Ok(None) = CLOUD_AUTH.get_or_refresh_sync_token().await {
log::warn!("Sync token missing despite an active subscription");
}
// Request wayfern token for paid users
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token after login: {e}");
}
let _ = CLOUD_AUTH.request_wayfern_token().await;
}
// Sync cloud proxy after login
@@ -1873,9 +1893,7 @@ pub(crate) async fn ensure_remote_bridge(app_handle: &tauri::AppHandle) {
.map(|settings| settings.mcp_remote_enabled)
.unwrap_or(false);
if enabled {
log::info!(
"[mcp-remote] Remote control is enabled and the account is signed in; opening the bridge"
);
log::info!("Remote control bridge opening: enabled and signed in");
crate::mcp_remote::start(app_handle.clone());
}
}
@@ -1903,9 +1921,8 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
if CLOUD_AUTH.is_entitled_to_wayfern_token().await
&& CLOUD_AUTH.get_wayfern_token().await.is_none()
{
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Refresh could not obtain a wayfern token: {e}");
}
// Failures are logged inside.
let _ = CLOUD_AUTH.request_wayfern_token().await;
}
Ok(user)
@@ -2070,13 +2087,16 @@ pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String>
})
.await
{
Ok(usage) => Ok(Some(merge_proxy_usage(
&usage,
cached_recurring,
cached_extra,
))),
Ok(usage) => {
PROXY_USAGE_FETCH.succeeded();
Ok(Some(merge_proxy_usage(
&usage,
cached_recurring,
cached_extra,
)))
}
Err(e) => {
log::warn!("Failed to fetch live proxy usage, falling back to cached: {e}");
PROXY_USAGE_FETCH.failed(format!("{e}; using cached usage"));
// Fallback to cached values
let state = CLOUD_AUTH.state.lock().await;
match &*state {
+2 -2
View File
@@ -78,11 +78,11 @@ impl CommercialLicenseManager {
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
log::info!("First launch timestamp recorded: {now}");
log::info!("First launch recorded at={now}");
// Emit event to notify frontend
if let Err(e) = events::emit("first-launch-recorded", now) {
log::warn!("Failed to emit first-launch-recorded event: {e}");
log::warn!("Event not emitted event=first-launch-recorded err=\"{e}\"");
}
Ok(now)
+9 -6
View File
@@ -950,20 +950,23 @@ async fn send_profile_state(profile_id: String, state: ProfileState, identity: P
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
match update_profile_state(&profile_id, state, &identity).await {
Ok(_) => {
STATE_REPORT.succeeded(&format!("profile={profile_id}"));
record_enrolment(&mut enrolled_profiles(), &profile_id, true);
log::debug!("Re-declared cookie-bot profile state for {profile_id}");
log::debug!("Cookie bot profile state re-declared profile={profile_id}");
}
// Not enrolled is the common answer and not worth a log line at warn.
Err(e) if e.code() == "COOKIE_BOT_NOT_ENROLLED" => {
STATE_REPORT.succeeded(&format!("profile={profile_id}"));
record_enrolment(&mut enrolled_profiles(), &profile_id, false);
}
Err(e) => {
log::warn!("Could not re-declare cookie-bot profile state for {profile_id}: {e}");
}
Err(e) => STATE_REPORT.failed(&format!("profile={profile_id}"), e),
}
}
}
static STATE_REPORT: crate::log_streak::KeyedStreak =
crate::log_streak::KeyedStreak::new(module_path!(), "Cookie bot profile state report");
pub fn report_profiles_using_proxy(proxy_id: &str) {
report_matching(|profile| profile.proxy_id.as_deref() == Some(proxy_id));
}
@@ -989,7 +992,7 @@ pub async fn report_enrolled_profiles() {
let list = match list_schedules(None).await {
Ok(list) => list,
Err(e) => {
log::debug!("Could not list cookie-bot schedules at startup: {e}");
log::debug!("Cookie bot schedules not listed at startup err=\"{e}\"");
return;
}
};
@@ -1361,7 +1364,7 @@ pub async fn delete_user_template(id: &str) -> Result<bool, CookieBotError> {
/// in a Japanese UI is the failure the `{"code":…}` convention exists to stop.
fn command_error(context: &str, err: CookieBotError) -> String {
log::warn!(
"Cookie bot {context} failed: {} (HTTP {})",
"Cookie bot request failed action=\"{context}\" code={} status={}",
err.code(),
err.status()
);
+6 -4
View File
@@ -301,9 +301,9 @@ mod windows {
/// alone.
fn remove_legacy_registration(root: &RegKey) {
match root.delete_subkey_all(LEGACY_APP_KEY) {
Ok(()) => log::debug!("Removed the superseded default-browser registration key"),
Ok(()) => log::debug!("Superseded default-browser registry key removed"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => log::debug!("Could not remove the superseded registration key: {e}"),
Err(e) => log::debug!("Superseded default-browser registry key removal failed err=\"{e}\""),
}
for extension in [".htm", ".html"] {
@@ -319,8 +319,10 @@ mod windows {
if ours {
match key.delete_value("") {
Ok(()) => log::debug!("Released the {extension} association taken by an older build"),
Err(e) => log::debug!("Could not release the {extension} association: {e}"),
Ok(()) => log::debug!("Older build's file association released extension={extension}"),
Err(e) => log::debug!(
"Older build's file association release failed extension={extension} err=\"{e}\""
),
}
}
}
+52 -39
View File
@@ -346,16 +346,11 @@ impl BlocklistManager {
// refused it: the outage this replaced was one CDN answering 403 for a
// reason that had nothing to do with the user, and falling back would have
// made it invisible.
let started = std::time::Instant::now();
let mut body: Option<String> = None;
let mut failures: Vec<String> = Vec::new();
for url in &urls {
log::info!(
"[dns-blocklist] Fetching {} from {}",
level.display_name(),
url
);
let response = match HTTP_CLIENT.get(url).send().await {
Ok(response) => response,
Err(e) => {
@@ -371,18 +366,6 @@ impl BlocklistManager {
match response.text().await {
Ok(text) => {
if failures.is_empty() {
log::info!("[dns-blocklist] {} fetched", level.display_name());
} else {
// Worth saying out loud: the primary source is down and somebody
// should know before the backup goes too.
log::warn!(
"[dns-blocklist] {} came from a fallback source after {} failure(s): {}",
level.display_name(),
failures.len(),
failures.join("; ")
);
}
body = Some(text);
break;
}
@@ -407,10 +390,25 @@ impl BlocklistManager {
.lines()
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.count();
log::info!(
"[dns-blocklist] Cached {} ({} domains)",
level.display_name(),
entry_count
// A fallback is worth a warning: the primary source is down and somebody
// should know before the backup goes too.
let log_level = if failures.is_empty() {
log::Level::Info
} else {
log::Level::Warn
};
log::log!(
log_level,
"Blocklist fetched level={} domains={entry_count} source={}/{} elapsed_ms={}{}",
level.as_str(),
failures.len() + 1,
urls.len(),
started.elapsed().as_millis(),
if failures.is_empty() {
String::new()
} else {
format!(" failed_sources=\"{}\"", failures.join("; "))
}
);
Ok(path)
@@ -461,22 +459,25 @@ impl BlocklistManager {
} else {
// Fetch every source concurrently. Sequential awaits make this the sum of
// all source latencies, and it runs on the blocking profile-launch path.
// Custom sources are the person's own URLs: the log names them by
// position and host only.
let fetches = config.sources.iter().map(|source| async move {
match HTTP_CLIENT.get(source).send().await {
Ok(resp) if resp.status().is_success() => resp
.text()
.await
.map_err(|e| format!("custom source {source} body read failed: {e}")),
Ok(resp) => Err(format!(
"custom source {source} returned HTTP {}",
resp.status()
)),
Err(e) => Err(format!("custom source {source} failed: {e}")),
.map_err(|e| format!("body read failed: {}", e.without_url())),
Ok(resp) => Err(format!("status={}", resp.status().as_u16())),
Err(e) => Err(e.without_url().to_string()),
}
});
let mut source_failures = 0usize;
for result in futures_util::future::join_all(fetches).await {
for (index, result) in futures_util::future::join_all(fetches)
.await
.into_iter()
.enumerate()
{
match result {
Ok(body) => {
for line in body.lines() {
@@ -486,7 +487,13 @@ impl BlocklistManager {
}
}
Err(e) => {
log::warn!("[dns-blocklist] {e}");
let host = config
.sources
.get(index)
.and_then(|source| url::Url::parse(source).ok())
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| "-".to_string());
log::warn!("Custom blocklist source failed source={index} host={host} err=\"{e}\"");
source_failures += 1;
}
}
@@ -509,12 +516,12 @@ impl BlocklistManager {
}
}
log::warn!(
"[dns-blocklist] {source_failures} custom source(s) failed; retained {} domain(s) from the cached list",
"Custom blocklist kept cached domains for failed sources failed={source_failures} retained={}",
domains.len().saturating_sub(before)
);
}
Err(_) => log::warn!(
"[dns-blocklist] {source_failures} custom source(s) failed and no cached list exists; custom filtering is incomplete"
"Custom blocklist incomplete: sources failed and no cached list exists failed={source_failures}"
),
}
}
@@ -546,20 +553,21 @@ impl BlocklistManager {
.map_err(|e| format!("Failed to rename custom blocklist: {e}"))?;
log::info!(
"[dns-blocklist] Compiled custom blocklist ({} domains)",
domains.len()
"Custom blocklist compiled domains={} sources={} allowlist_mode={}",
domains.len(),
config.sources.len(),
config.allowlist_mode
);
Ok(path)
}
pub async fn refresh_all_stale(&self) {
let mut any_failed = false;
for &level in BlocklistLevel::all_downloadable() {
if !Self::is_cache_fresh(level) {
if let Err(e) = Self::fetch_blocklist(level).await {
log::error!(
"[dns-blocklist] Failed to refresh {}: {e}",
level.display_name()
);
any_failed = true;
REFRESH_STREAK.failed(format!("level={} {e}", level.as_str()));
let _ = crate::events::emit(
"dns-blocklist-refresh-failed",
serde_json::json!({
@@ -570,11 +578,14 @@ impl BlocklistManager {
}
}
}
if !any_failed {
REFRESH_STREAK.succeeded();
}
// Recompile the custom list too so its sources track upstream changes.
let config = CustomDnsConfig::load();
if !config.sources.is_empty() || !config.block_domains.is_empty() {
if let Err(e) = Self::compile_custom_blocklist().await {
log::error!("[dns-blocklist] Failed to recompile custom list: {e}");
log::warn!("Custom blocklist recompile failed err=\"{e}\"");
}
}
}
@@ -629,6 +640,8 @@ impl BlocklistManager {
}
static BLOCKLIST_MANAGER: BlocklistManager = BlocklistManager;
static REFRESH_STREAK: crate::log_streak::Streak =
crate::log_streak::Streak::new(module_path!(), "Blocklist refresh");
// Tauri commands
+119 -149
View File
@@ -49,6 +49,20 @@ fn is_download_artifact(file_name: &str) -> bool {
.any(|suffix| lowered.ends_with(suffix))
}
/// Said once per browser type: every maintenance pass meets the same entries.
fn note_unsupported_browser(browser: &str) {
static SEEN: Mutex<Vec<String>> = Mutex::new(Vec::new());
let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
if seen.iter().any(|known| known == browser) {
return;
}
seen.push(browser.to_string());
log::warn!(
"Unsupported browser type, entries ignored browser={}",
crate::log_redaction::Plain(browser)
);
}
impl DownloadedBrowsersRegistry {
fn new() -> Self {
Self {
@@ -135,7 +149,7 @@ impl DownloadedBrowsersRegistry {
let browser_type = match BrowserType::from_str(browser) {
Ok(bt) => bt,
Err(_) => {
log::info!("Invalid browser type: {browser}");
note_unsupported_browser(browser);
return false;
}
};
@@ -147,7 +161,7 @@ impl DownloadedBrowsersRegistry {
// If files don't exist but registry thinks they do, clean up the registry
if !files_exist {
log::info!("Cleaning up stale registry entry for {browser} {version}");
log::info!("Removed stale registry entry browser={browser} version={version}");
self.remove_browser(browser, version);
let _ = self.save(); // Don't fail if save fails, just log
}
@@ -167,10 +181,8 @@ impl DownloadedBrowsersRegistry {
pub fn mark_download_started(&self, browser: &str, version: &str, file_path: PathBuf) {
// Only mark download started, don't add to registry yet
// The browser will be added to registry only after verification succeeds
log::info!(
"Marking download started for {}:{} at {}",
browser,
version,
log::debug!(
"Download started browser={browser} version={version} path={}",
file_path.display()
);
}
@@ -188,7 +200,7 @@ impl DownloadedBrowsersRegistry {
file_path,
};
self.add_browser(info);
log::info!("Browser {browser}:{version} successfully added to registry after verification");
log::debug!("Registered browser={browser} version={version}");
Ok(())
}
@@ -202,7 +214,7 @@ impl DownloadedBrowsersRegistry {
// maintenance task land here, and a freshly downloaded version is referenced
// by no persisted profile while profile creation is still in flight.
if crate::downloader::is_downloading(browser, version) {
log::info!("Skipping cleanup of {browser} {version}: a download is in progress");
log::debug!("Cleanup skipped, download in progress browser={browser} version={version}");
return Ok(());
}
@@ -262,7 +274,7 @@ impl DownloadedBrowsersRegistry {
let pending_updates = match self.auto_updater.get_pending_update_versions() {
Ok(updates) => updates,
Err(e) => {
log::warn!("Warning: Failed to get pending updates for cleanup: {e}");
log::warn!("Pending update lookup failed, cleanup ignores pending updates err=\"{e}\"");
std::collections::HashSet::new()
}
};
@@ -277,13 +289,13 @@ impl DownloadedBrowsersRegistry {
// Don't remove if it's used by any active profile
if active_set.contains(&browser_version) {
log::info!("Keeping: {browser} {version} (in use by profile)");
log::debug!("Keeping browser={browser} version={version} reason=in_use");
continue;
}
// Don't remove if it's currently running (even if not in active profiles)
if running_set.contains(&browser_version) {
log::info!("Keeping: {browser} {version} (currently running)");
log::debug!("Keeping browser={browser} version={version} reason=running");
continue;
}
@@ -294,14 +306,14 @@ impl DownloadedBrowsersRegistry {
let has_running_profile_for_browser =
running_profiles.iter().any(|(b, _)| b == browser);
if has_running_profile_for_browser {
log::info!("Keeping: {browser} {version} (pending update for running profile)");
log::debug!("Keeping browser={browser} version={version} reason=pending_update");
continue;
}
}
// Mark for removal
to_remove.push(browser_version);
log::info!("Marking for removal: {browser} {version} (not used by any profile)");
log::debug!("Unused browser={browser} version={version}");
}
}
}
@@ -344,7 +356,7 @@ impl DownloadedBrowsersRegistry {
.get(browser)
.is_some_and(|keep| keep == version)
{
log::info!("Keeping latest available version: {browser} {version}");
log::debug!("Keeping browser={browser} version={version} reason=latest");
return false;
}
true
@@ -354,21 +366,24 @@ impl DownloadedBrowsersRegistry {
// Remove unused binaries and their version folders
for (browser, version) in to_remove {
if let Err(e) = self.cleanup_failed_download(&browser, &version) {
log::error!("Failed to cleanup unused binary {browser}:{version}: {e}");
log::warn!("Unused browser removal failed browser={browser} version={version} err=\"{e}\"");
} else {
// After removing the binary, also remove the empty version folder
if let Err(e) = self.remove_empty_version_folder(&browser, &version) {
log::error!("Failed to remove empty version folder for {browser}:{version}: {e}");
log::warn!(
"Empty version folder removal failed browser={browser} version={version} err=\"{e}\""
);
}
cleaned_up.push(format!("{browser} {version}"));
log::info!("Successfully removed unused binary: {browser} {version}");
}
}
if cleaned_up.is_empty() {
log::info!("No unused binaries found to clean up");
} else {
log::info!("Cleaned up {} unused binaries", cleaned_up.len());
if !cleaned_up.is_empty() {
log::info!(
"Removed unused browsers count={} versions=\"{}\"",
cleaned_up.len(),
cleaned_up.join(", ")
);
}
Ok(cleaned_up)
@@ -424,7 +439,7 @@ impl DownloadedBrowsersRegistry {
// Files don't exist, remove from registry
if let Some(_removed) = self.remove_browser(&browser_str, &version) {
cleaned_up.push(format!("{browser_str} {version}"));
log::info!("Removed stale registry entry for {browser_str} {version}");
log::info!("Removed stale registry entry browser={browser_str} version={version}");
}
}
}
@@ -576,7 +591,7 @@ impl DownloadedBrowsersRegistry {
if entries.next().is_none() {
// Directory is empty, remove it
fs::remove_dir(&version_dir)?;
log::info!("Removed empty version folder: {}", version_dir.display());
log::debug!("Removed empty version folder browser={browser} version={version}");
// Also check if the browser folder is now empty and remove it too
let browser_dir = binaries_dir.join(browser);
@@ -584,7 +599,7 @@ impl DownloadedBrowsersRegistry {
if let Ok(mut browser_entries) = fs::read_dir(&browser_dir) {
if browser_entries.next().is_none() {
fs::remove_dir(&browser_dir)?;
log::info!("Removed empty browser folder: {}", browser_dir.display());
log::debug!("Removed empty browser folder browser={browser}");
}
}
}
@@ -671,15 +686,13 @@ impl DownloadedBrowsersRegistry {
// Remove empty version directories
for (version_path, version_name) in empty_version_dirs {
if let Err(e) = fs::remove_dir(&version_path) {
log::error!(
"Failed to remove empty version folder {}: {e}",
version_path.display()
log::warn!(
"Empty version folder removal failed browser={browser_name} version={version_name} err=\"{e}\""
);
} else {
cleaned_up.push(format!(
"Removed empty version folder: {browser_name}/{version_name}"
));
log::info!("Removed empty version folder: {}", version_path.display());
}
}
@@ -688,13 +701,9 @@ impl DownloadedBrowsersRegistry {
if let Ok(mut entries) = fs::read_dir(&browser_path) {
if entries.next().is_none() {
if let Err(e) = fs::remove_dir(&browser_path) {
log::error!(
"Failed to remove empty browser folder {}: {e}",
browser_path.display()
);
log::warn!("Empty browser folder removal failed browser={browser_name} err=\"{e}\"");
} else {
cleaned_up.push(format!("Removed empty browser folder: {browser_name}"));
log::info!("Removed empty browser folder: {}", browser_path.display());
}
}
}
@@ -730,9 +739,9 @@ impl DownloadedBrowsersRegistry {
profiles_to_update.push(*profile);
older_versions_to_remove.insert(profile.version.clone());
} else {
log::info!(
"Skipping version update for running profile: {} ({})",
profile.name,
log::debug!(
"Version update deferred, browser running profile={} version={}",
profile.id,
profile.version
);
}
@@ -744,26 +753,29 @@ impl DownloadedBrowsersRegistry {
match update_profile(profile) {
Ok(()) => {
consolidated.push(format!(
"Updated profile '{}' from {} to {}",
profile.name, profile.version, latest_version
"profile {} {} -> {}",
profile.id, profile.version, latest_version
));
}
Err(e) => {
log::error!("Failed to update profile '{}': {}", profile.name, e);
log::warn!(
"Profile browser update failed profile={} to={latest_version} err=\"{e}\"",
profile.id
);
}
}
}
// Remove older version binaries that are no longer needed
for old_version in &older_versions_to_remove {
log::info!("Consolidating: removing old version {browser_name} {old_version}");
match remove_version(old_version.as_str()) {
Ok(()) => {
consolidated.push(format!("Removed old version: {browser_name} {old_version}"));
log::info!("Successfully removed old version: {browser_name} {old_version}");
}
Err(e) => {
log::error!("Failed to cleanup old version {browser_name} {old_version}: {e}");
log::warn!(
"Old browser removal failed browser={browser_name} version={old_version} err=\"{e}\""
);
}
}
}
@@ -776,8 +788,6 @@ impl DownloadedBrowsersRegistry {
&self,
app_handle: &tauri::AppHandle,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
log::info!("Starting browser version consolidation...");
let profiles = self
.profile_manager
.list_profiles()
@@ -811,16 +821,15 @@ impl DownloadedBrowsersRegistry {
if browser.is_version_downloaded(&profile.version, &binaries_dir) {
available_versions.push(profile.version.clone());
} else {
log::info!(
"Profile '{}' references version {} that doesn't exist on disk",
profile.name,
log::debug!(
"Profile browser not on disk profile={} browser={browser_name} version={}",
profile.id,
profile.version
);
}
}
if available_versions.is_empty() {
log::info!("No available versions found for {browser_name}, skipping consolidation");
continue;
}
@@ -831,7 +840,6 @@ impl DownloadedBrowsersRegistry {
});
let latest_version = &available_versions[0];
log::info!("Latest available version for {browser_name}: {latest_version}");
let mut consolidated_for_browser = Self::consolidate_profiles_for_browser(
browser_name,
@@ -858,10 +866,13 @@ impl DownloadedBrowsersRegistry {
.save()
.map_err(|e| format!("Failed to save registry after consolidation: {e}"))?;
log::info!(
"Browser version consolidation completed: {} actions taken",
consolidated.len()
);
if !consolidated.is_empty() {
log::info!(
"Browser versions consolidated count={} actions=\"{}\"",
consolidated.len(),
consolidated.join("; ")
);
}
Ok(consolidated)
}
@@ -881,11 +892,7 @@ impl DownloadedBrowsersRegistry {
let browser_type = match BrowserType::from_str(&profile.browser) {
Ok(bt) => bt,
Err(_) => {
log::info!(
"Warning: Invalid browser type '{}' for profile '{}'",
profile.browser,
profile.name
);
note_unsupported_browser(&profile.browser);
continue;
}
};
@@ -894,11 +901,6 @@ impl DownloadedBrowsersRegistry {
let binaries_dir = crate::app_dirs::binaries_dir();
log::info!(
"binaries_dir: {binaries_dir:?} for profile: {}",
profile.name
);
// Check if the version is downloaded
if !browser.is_version_downloaded(&profile.version, &binaries_dir) {
missing_binaries.push((profile.name, profile.browser, profile.version));
@@ -914,77 +916,48 @@ impl DownloadedBrowsersRegistry {
app_handle: &tauri::AppHandle,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
// First, clean up any stale registry entries
if let Ok(cleaned_up) = self.verify_and_cleanup_stale_entries() {
if !cleaned_up.is_empty() {
log::info!(
"Cleaned up {} stale registry entries: {}",
cleaned_up.len(),
cleaned_up.join(", ")
);
}
}
let _ = self.verify_and_cleanup_stale_entries();
// Consolidate browser versions - keep only latest version per browser
if let Ok(consolidated) = self.consolidate_browser_versions(app_handle) {
if !consolidated.is_empty() {
log::info!("Version consolidation results:");
for action in &consolidated {
log::info!(" {action}");
}
}
}
let _ = self.consolidate_browser_versions(app_handle);
let missing_binaries = self.check_missing_binaries().await?;
let mut downloaded = Vec::new();
for (profile_name, browser, version) in missing_binaries {
log::info!("Downloading missing binary for profile '{profile_name}': {browser} {version}");
match crate::downloader::download_browser(
app_handle.clone(),
browser.clone(),
version.clone(),
)
.await
// A failed download logs itself.
if crate::downloader::download_browser(app_handle.clone(), browser.clone(), version.clone())
.await
.is_ok()
{
Ok(_) => {
downloaded.push(format!(
"{browser} {version} (for profile '{profile_name}')"
));
downloaded.push(format!(
"{browser} {version} (for profile '{profile_name}')"
));
// After successful download, update profiles that use this browser to the new version
match self
.update_profiles_to_version(app_handle, &browser, &version)
.await
{
Ok(updated_profiles) => {
if !updated_profiles.is_empty() {
log::info!(
"Successfully updated {} profiles to version {}:",
updated_profiles.len(),
version
);
for update_msg in updated_profiles {
log::info!(" {update_msg}");
}
}
}
Err(e) => {
log::error!("CRITICAL: Failed to update profiles to version {version}: {e}");
log::error!("This may cause profile version inconsistencies and cleanup issues");
// After successful download, update profiles that use this browser to the new version
match self
.update_profiles_to_version(app_handle, &browser, &version)
.await
{
Ok(updated_profiles) => {
if !updated_profiles.is_empty() {
log::info!(
"Profiles moved to downloaded browser count={} browser={browser} version={version}",
updated_profiles.len()
);
}
}
}
Err(e) => {
log::error!("Failed to download {browser} {version} for profile '{profile_name}': {e}");
Err(e) => {
log::error!(
"Moving profiles to downloaded browser failed browser={browser} version={version} err=\"{e}\""
);
}
}
}
}
// Check if GeoIP database is missing for Wayfern profiles
if self.geoip_downloader.check_missing_geoip_database()? {
log::info!("GeoIP database is missing for Wayfern profiles, downloading...");
match self
.geoip_downloader
.download_geoip_database(app_handle)
@@ -992,10 +965,12 @@ impl DownloadedBrowsersRegistry {
{
Ok(_) => {
downloaded.push("GeoIP database".to_string());
log::info!("GeoIP database downloaded successfully");
}
Err(e) => {
log::error!("Failed to download GeoIP database: {e}");
log::error!(
"GeoIP database download failed err=\"{}\"",
crate::log_redaction::text(&e.to_string())
);
// Don't fail the entire operation if GeoIP download fails
}
}
@@ -1022,9 +997,9 @@ impl DownloadedBrowsersRegistry {
if profile.browser == browser && profile.version != version {
// Check if profile is currently running
if profile.process_id.is_some() {
log::info!(
"Skipping version update for running profile: {} ({})",
profile.name,
log::debug!(
"Version update deferred, browser running profile={} version={}",
profile.id,
profile.version
);
continue;
@@ -1038,22 +1013,20 @@ impl DownloadedBrowsersRegistry {
) {
Ok(_) => {
updated_profiles.push(format!(
"Updated profile '{}' from {} to {}",
profile.name, profile.version, version
"profile {} {} -> {}",
profile.id, profile.version, version
));
log::info!(
"Successfully updated profile '{}' to version {}",
profile.name,
version
);
// Save registry after each profile update to ensure consistency
if let Err(e) = self.save() {
log::warn!("Warning: Failed to save registry after profile update: {e}");
log::warn!("Registry save failed err=\"{e}\"");
}
}
Err(e) => {
log::error!("Failed to update profile '{}': {}", profile.name, e);
log::warn!(
"Profile browser update failed profile={} to={version} err=\"{e}\"",
profile.id
);
}
}
}
@@ -1095,7 +1068,7 @@ static DOWNLOADED_BROWSERS_REGISTRY: std::sync::LazyLock<DownloadedBrowsersRegis
std::sync::LazyLock::new(|| {
let registry = DownloadedBrowsersRegistry::new();
if let Err(e) = registry.load() {
log::warn!("Warning: Failed to load downloaded browsers registry: {e}");
log::warn!("Browser registry load failed err=\"{e}\"");
}
registry
});
@@ -1534,7 +1507,7 @@ pub async fn ensure_active_browsers_downloaded(
if crate::e2e_automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping proactive browser download");
log::debug!("E2E: skipping proactive browser download");
return Ok(Vec::new());
}
@@ -1546,13 +1519,8 @@ pub async fn ensure_active_browsers_downloaded(
// Check if any version is already downloaded
let existing = registry.get_downloaded_versions(browser);
if !existing.is_empty() {
log::info!(
"ensure_active: Skipping {browser}: already have {} version(s) downloaded",
existing.len()
);
continue;
}
log::info!("ensure_active: No {browser} versions found, will download");
// Resolve the version to download. For wayfern, only the currently
// published version is downloadable, so ask the API fresh — the release-type
@@ -1565,7 +1533,10 @@ pub async fn ensure_active_browsers_downloaded(
{
Ok(info) => info.version,
Err(e) => {
log::warn!("Failed to resolve current {browser} version: {e}");
log::warn!(
"Browser version lookup failed browser={browser} err=\"{}\"",
crate::log_redaction::text(&e.to_string())
);
// The first-run screen listens for this. Without it the screen
// waits at 0% for a download that never starts (issue #625).
let _ = crate::events::emit(
@@ -1580,7 +1551,10 @@ pub async fn ensure_active_browsers_downloaded(
let release_types = match version_manager.get_browser_release_types(browser).await {
Ok(rt) => rt,
Err(e) => {
log::warn!("Failed to get release types for {browser}: {e}");
log::warn!(
"Browser release lookup failed browser={browser} err=\"{}\"",
crate::log_redaction::text(&e.to_string())
);
continue;
}
};
@@ -1595,8 +1569,6 @@ pub async fn ensure_active_browsers_downloaded(
}
};
log::info!("Auto-downloading {browser} {version} (no versions found locally)");
// Retry transient failures a few times. Each attempt is wrapped in an overall
// timeout so that a hang anywhere in the download pipeline (version resolution,
// a stalled stream, extraction) cannot block the next browser forever. This is
@@ -1618,14 +1590,10 @@ pub async fn ensure_active_browsers_downloaded(
match result {
Ok(Ok(_)) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
succeeded = true;
break;
}
Ok(Err(e)) => {
log::warn!(
"Failed to auto-download {browser} {version} (attempt {attempt}/{MAX_ATTEMPTS}): {e}"
);
// A proxy setting stays wrong until the user changes it.
if crate::system_proxy::is_unreachable_error(&e.to_string()) {
break;
@@ -1637,7 +1605,7 @@ pub async fn ensure_active_browsers_downloaded(
// (by browser prefix, as a catch-all for whatever key was in flight) and
// emit a terminal error event so the UI stops spinning.
log::warn!(
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
"Browser auto-download timed out browser={browser} version={version} attempt={attempt}/{MAX_ATTEMPTS} timeout_s={}",
ATTEMPT_TIMEOUT.as_secs()
);
crate::downloader::clear_download_state_for_browser(browser);
@@ -1656,7 +1624,9 @@ pub async fn ensure_active_browsers_downloaded(
if !succeeded {
// Do NOT abort the whole routine: continue with remaining browsers
// still gets its chance even though this one failed/timed out.
log::warn!("Giving up on auto-download of {browser} {version} after {MAX_ATTEMPTS} attempts");
log::error!(
"Browser auto-download gave up browser={browser} version={version} attempts={MAX_ATTEMPTS}"
);
}
}
@@ -1692,7 +1662,7 @@ pub async fn ensure_all_binaries_exist(
if crate::e2e_automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping proactive binary and GeoIP downloads");
log::debug!("E2E: skipping proactive binary and GeoIP downloads");
return Ok(Vec::new());
}
+76 -73
View File
@@ -248,15 +248,9 @@ impl Downloader {
let file_path = dest_path.join(&download_info.filename);
// Resolve the actual download URL
log::info!(
"Resolving download URL for {} {}",
browser_type.as_str(),
version
);
let download_url = self
.resolve_download_url(browser_type.clone(), version, download_info)
.await?;
log::info!("Download URL resolved");
// Every browser asset is published with a `<asset>.sha256` sidecar. Fetch
// it before the transfer starts: an asset nobody can verify costs one small
@@ -331,13 +325,16 @@ impl Downloader {
Ok(response) if response.status().is_success() => response,
Ok(response) => {
log::warn!(
"Checksum sidecar request failed for {browser} {version}: HTTP {}",
response.status()
"Checksum sidecar request failed browser={browser} version={version} status={}",
response.status().as_u16()
);
return Err(unavailable());
}
Err(e) => {
log::warn!("Checksum sidecar request failed for {browser} {version}: {e}");
log::warn!(
"Checksum sidecar request failed browser={browser} version={version} err=\"{}\"",
crate::log_redaction::text(&e.to_string())
);
return Err(crate::system_proxy::explain(&e).map_or_else(unavailable, Into::into));
}
};
@@ -345,14 +342,17 @@ impl Downloader {
let sidecar_text = match response.text().await {
Ok(text) => text,
Err(e) => {
log::warn!("Failed to read the checksum sidecar for {browser} {version}: {e}");
log::warn!("Checksum sidecar read failed browser={browser} version={version} err=\"{e}\"");
return Err(unavailable());
}
};
let asset_name = asset_filename_from_url(download_url);
let Some(expected) = crate::checksum::parse_sidecar_digest(&sidecar_text, asset_name) else {
log::warn!("No usable digest for {asset_name} in {sidecar_url}");
log::warn!(
"Checksum sidecar has no digest for the asset browser={browser} version={version} asset={}",
Plain(asset_name)
);
return Err(unavailable());
};
Ok(expected)
@@ -397,13 +397,13 @@ impl Downloader {
request = request.header("Range", format!("bytes={existing_size}-"));
}
log::info!("Sending download request (attempt {})...", attempt + 1);
match request.send().await {
Ok(resp) => {
log::info!(
"Download response received: status={}, content-length={:?}",
resp.status(),
resp.content_length()
log::debug!(
"Download response status={} content_length={:?} offset={existing_size} attempt={}",
resp.status().as_u16(),
resp.content_length(),
attempt + 1
);
if resp.status().as_u16() == 416 && existing_size > 0 {
// The requested range is past the end of the object. Parse
@@ -421,16 +421,12 @@ impl Downloader {
None => true,
};
if partial_is_complete {
log::info!(
"Archive {} already complete ({} bytes), skipping download",
file_path.display(),
existing_size
);
log::debug!("Archive already complete bytes={existing_size}");
return Ok(file_path);
}
let _ = std::fs::remove_file(&file_path);
existing_size = 0;
log::warn!("Download returned 416 with an incomplete partial, restarting from 0");
log::debug!("Download range refused for an incomplete partial, restarting from 0");
continue;
}
response = Some(resp);
@@ -442,18 +438,21 @@ impl Downloader {
// bar at 0% for minutes before saying the same thing.
if attempt > 0 {
if let Some(proxy_error) = crate::system_proxy::explain(&e) {
log::warn!("Download request through the system proxy failed: {e}");
log::warn!(
"Download request failed through the system proxy attempt={} err=\"{}\"",
attempt + 1,
crate::log_redaction::text(&e.to_string())
);
return Err(proxy_error.into());
}
}
let is_retryable = e.is_connect() || e.is_timeout() || e.is_request();
if is_retryable && attempt < max_send_retries {
let delay = 2u64.pow(attempt.min(4));
log::warn!(
"Download attempt {} failed ({}), retrying in {}s...",
log::debug!(
"Download request failed attempt={} retry_in_s={delay} err=\"{}\"",
attempt + 1,
e,
delay
crate::log_redaction::text(&e.to_string())
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
} else {
@@ -512,11 +511,7 @@ impl Downloader {
if existing_size > 0 {
if let Some(total) = total_size {
if existing_size >= total {
log::info!(
"Archive {} already complete ({} bytes), skipping download",
file_path.display(),
existing_size
);
log::debug!("Archive already complete bytes={existing_size}");
return Ok(file_path);
}
}
@@ -654,13 +649,10 @@ impl Downloader {
}
stream_restarts += 1;
let delay = 2u64.pow(stream_restarts.min(4));
log::warn!(
"{} — resuming from {} bytes (restart {}/{}) in {}s",
err,
log::debug!(
"Download stream broke, resuming offset={} restart={stream_restarts}/{max_stream_restarts} retry_in_s={delay} err=\"{}\"",
std::fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0),
stream_restarts,
max_stream_restarts,
delay
crate::log_redaction::text(&err.to_string())
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
}
@@ -710,6 +702,7 @@ impl Downloader {
}
}
let started = std::time::Instant::now();
// Check if this browser-version pair is already being downloaded
let download_key = format!("{browser_str}-{version}");
let cancel_token = {
@@ -739,7 +732,7 @@ impl Downloader {
} else {
// Registry says it's downloaded but files don't exist - clean up registry
log::info!(
"Registry indicates {} {} is downloaded, but files are missing. Cleaning up registry entry.",
"Removed stale registry entry browser={} version={}",
Plain(&browser_str),
Plain(&version)
);
@@ -842,15 +835,8 @@ impl Downloader {
// Do not remove the archive here. We keep it until verification succeeds.
}
Err(e) => {
log::error!(
"Extraction failed for {} {}: {e}",
Plain(&browser_str),
Plain(&version)
);
// Delete the corrupt/invalid archive so a fresh download happens next time
if download_path.exists() {
log::info!("Deleting corrupt archive: {}", download_path.display());
let _ = std::fs::remove_file(&download_path);
}
@@ -894,12 +880,6 @@ impl Downloader {
let _ = events::emit("download-progress", &progress);
// Verify the browser was downloaded correctly
log::info!(
"Verifying download for browser: {}, version: {}",
Plain(&browser_str),
Plain(&version)
);
// Use the browser's own verification method
if !browser.is_version_downloaded(&version, &binaries_dir) {
// Provide detailed error information for debugging
@@ -955,7 +935,7 @@ impl Downloader {
.mark_download_completed(&browser_str, &version, browser_dir.clone())
{
log::warn!(
"Warning: Could not mark {} {} as completed in registry: {e}",
"Registry update failed browser={} version={} err=\"{e}\"",
Plain(&browser_str),
Plain(&version)
);
@@ -970,11 +950,18 @@ impl Downloader {
let archive_path = browser_dir.join(&download_info.filename);
if archive_path.exists() {
if let Err(e) = std::fs::remove_file(&archive_path) {
log::warn!("Warning: Could not delete archive file after verification: {e}");
log::warn!("Archive delete after install failed err=\"{e}\"");
}
}
}
log::info!(
"Browser installed browser={} version={} elapsed_ms={}",
Plain(&browser_str),
Plain(&version),
started.elapsed().as_millis()
);
// Emit completion
let progress = DownloadProgress {
browser: browser_str.clone(),
@@ -1012,13 +999,18 @@ impl Downloader {
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Applied {browser_for_update} {version_for_update} to profiles: {updated:?}"
"Profiles moved to installed browser count={} browser={} version={}",
updated.len(),
Plain(&browser_for_update),
Plain(&version_for_update)
);
}
}
Err(e) => {
log::error!(
"Failed to apply {browser_for_update} {version_for_update} to profiles: {e}"
log::warn!(
"Moving profiles to installed browser failed browser={} version={} err=\"{e}\"",
Plain(&browser_for_update),
Plain(&version_for_update)
);
}
}
@@ -1027,27 +1019,19 @@ impl Downloader {
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Auto-updated {} profiles to latest installed versions: {:?}",
updated.len(),
updated
"Profiles moved to the latest installed browser count={}",
updated.len()
);
}
}
Err(e) => {
log::error!("Failed to auto-update profile versions: {e}");
log::warn!("Moving profiles to the latest installed browser failed err=\"{e}\"");
}
}
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
match registry.cleanup_unused_binaries() {
Ok(cleaned) => {
if !cleaned.is_empty() {
log::info!("Cleaned up unused binaries after download: {:?}", cleaned);
}
}
Err(e) => {
log::error!("Failed to cleanup unused binaries: {e}");
}
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Unused browser cleanup after install failed err=\"{e}\"");
}
});
}
@@ -1092,11 +1076,13 @@ async fn verify_archive_checksum(
})??;
if actual.eq_ignore_ascii_case(expected) {
log::info!("Checksum verified for {browser} {version}: {actual}");
log::debug!("Checksum verified browser={browser} version={version} sha256={actual}");
return Ok(());
}
log::error!("Checksum mismatch for {browser} {version}: expected {expected}, got {actual}");
log::warn!(
"Checksum mismatch, archive deleted browser={browser} version={version} expected={expected} actual={actual}"
);
let _ = std::fs::remove_file(file_path);
Err(
serde_json::json!({
@@ -1167,10 +1153,27 @@ pub async fn download_browser(
version: String,
) -> Result<String, String> {
let downloader = Downloader::instance();
let browser = Plain(&browser_str).to_string();
let requested = Plain(&version).to_string();
downloader
.download_browser_full(&app_handle, browser_str, version)
.await
.map_err(|e| crate::wrap_backend_error(e, "Failed to download browser"))
.map_err(|e| {
let err = e.to_string();
if err.contains("Download cancelled") {
log::info!("Browser download cancelled browser={browser} version={requested}");
} else if err.contains("is already being downloaded") {
log::info!(
"Browser download skipped, already running browser={browser} version={requested}"
);
} else {
log::error!(
"Browser download failed browser={browser} version={requested} err=\"{}\"",
crate::log_redaction::text(&err)
);
}
crate::wrap_backend_error(e, "Failed to download browser")
})
}
#[tauri::command]
+135 -40
View File
@@ -52,6 +52,32 @@ fn ephemeral_base_override() -> Option<PathBuf> {
None
}
#[cfg(target_os = "linux")]
const LINUX_EPHEMERAL_BASE: &str = "/dev/shm/donut-ephemeral";
#[cfg(target_os = "macos")]
const MACOS_RAM_DISK: &str = "/Volumes/DonutEphemeral";
fn disk_fallback_base() -> PathBuf {
std::env::temp_dir().join("donut-ephemeral")
}
/// Whether an earlier run left a base that may hold directories to recover.
/// Unlike `get_ephemeral_base_dir`, this never creates one.
fn earlier_base_exists() -> bool {
if let Some(base) = ephemeral_base_override() {
return base.exists();
}
#[cfg(target_os = "linux")]
let ram = Path::new(LINUX_EPHEMERAL_BASE).exists();
#[cfg(target_os = "macos")]
let ram = Path::new(MACOS_RAM_DISK).is_dir();
#[cfg(target_os = "windows")]
let ram = existing_windows_ramdisk().is_some();
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
let ram = false;
ram || disk_fallback_base().exists()
}
/// Get or create the base directory for ephemeral profiles, and report whether
/// it is actually RAM-backed.
///
@@ -68,7 +94,7 @@ fn get_ephemeral_base_dir() -> Result<(PathBuf, EphemeralBacking), String> {
#[cfg(target_os = "linux")]
{
let base = PathBuf::from("/dev/shm/donut-ephemeral");
let base = PathBuf::from(LINUX_EPHEMERAL_BASE);
std::fs::create_dir_all(&base)
.map_err(|e| format!("Failed to create ephemeral base in /dev/shm: {e}"))?;
Ok((base, EphemeralBacking::Ram))
@@ -99,11 +125,11 @@ fn get_ephemeral_base_dir() -> Result<(PathBuf, EphemeralBacking), String> {
// makes and the previous "may use disk" wording was logged unconditionally
// right before disk was used, so it read as speculative when it was
// certain. The cause used to be discarded entirely.
let base = std::env::temp_dir().join("donut-ephemeral");
let base = disk_fallback_base();
std::fs::create_dir_all(&base)
.map_err(|e| format!("Failed to create ephemeral base dir: {e}"))?;
log::error!(
"No RAM disk available ({ramdisk_error}); ephemeral profiles are being written to disk at {} and will be securely erased on teardown instead",
"No RAM disk; ephemeral profiles go to disk and are erased on teardown path={} err=\"{ramdisk_error}\"",
base.display()
);
Ok((base, EphemeralBacking::Disk))
@@ -112,7 +138,7 @@ fn get_ephemeral_base_dir() -> Result<(PathBuf, EphemeralBacking), String> {
#[cfg(target_os = "macos")]
fn get_or_create_macos_ramdisk() -> Result<PathBuf, String> {
let mount_point = PathBuf::from("/Volumes/DonutEphemeral");
let mount_point = PathBuf::from(MACOS_RAM_DISK);
// Reuse existing RAM disk from a previous session
if mount_point.exists() && mount_point.is_dir() {
@@ -150,18 +176,22 @@ fn get_or_create_macos_ramdisk() -> Result<PathBuf, String> {
));
}
log::info!("Created macOS RAM disk at {}", mount_point.display());
log::info!("RAM disk mounted path={MACOS_RAM_DISK} device={dev} size_mb=256");
Ok(mount_point)
}
#[cfg(target_os = "windows")]
fn existing_windows_ramdisk() -> Option<PathBuf> {
['R', 'Q', 'P', 'O']
.into_iter()
.map(|letter| PathBuf::from(format!("{}:\\DonutEphemeral", letter)))
.find(|base| base.is_dir())
}
#[cfg(target_os = "windows")]
fn get_or_create_windows_ramdisk() -> Result<PathBuf, String> {
// Check if a previous RAM disk with our directory already exists
for letter in ['R', 'Q', 'P', 'O'] {
let base = PathBuf::from(format!("{}:\\DonutEphemeral", letter));
if base.exists() && base.is_dir() {
return Ok(base);
}
if let Some(base) = existing_windows_ramdisk() {
return Ok(base);
}
// Try to create a RAM disk using imdisk (open-source RAM disk driver)
@@ -180,14 +210,13 @@ fn get_or_create_windows_ramdisk() -> Result<PathBuf, String> {
let base = PathBuf::from(format!("{}\\DonutEphemeral", drive));
std::fs::create_dir_all(&base)
.map_err(|e| format!("Failed to create dir on RAM disk: {e}"))?;
log::info!("Created Windows RAM disk at {}", base.display());
log::info!("RAM disk created path={} size_mb=256", base.display());
return Ok(base);
}
Ok(out) => {
log::debug!(
"imdisk failed for drive {}: {}",
drive,
String::from_utf8_lossy(&out.stderr)
"imdisk failed drive={drive} err=\"{}\"",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => {
@@ -220,13 +249,11 @@ pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
// anything, so a log could not be used to tell a RAM-backed session from a
// disk-backed one after the fact.
log::info!(
"Created {} ephemeral dir for profile {}: {}",
"Ephemeral dir created profile={profile_id} backing={}",
match backing {
EphemeralBacking::Ram => "RAM-backed",
EphemeralBacking::Disk => "DISK-backed (not in memory)",
},
profile_id,
dir_path.display()
EphemeralBacking::Ram => "ram",
EphemeralBacking::Disk => "disk",
}
);
Ok(dir_path)
@@ -248,7 +275,7 @@ pub fn remove_ephemeral_dir(profile_id: &str) -> bool {
let entry = match EPHEMERAL_DIRS.lock() {
Ok(map) => map.get(profile_id).map(|e| (e.path.clone(), e.backing)),
Err(e) => {
log::error!("Failed to lock ephemeral dirs while removing {profile_id}: {e}");
log::error!("Ephemeral dir removal failed profile={profile_id} err=\"lock: {e}\"");
return false;
}
};
@@ -272,16 +299,9 @@ pub fn remove_ephemeral_dir(profile_id: &str) -> bool {
map.remove(profile_id);
}
log::info!(
"Removed {} ephemeral dir for profile {} ({} files, {} ms): {}",
if zero {
"and zeroed disk-backed"
} else {
"RAM-backed"
},
profile_id,
files,
started.elapsed().as_millis(),
dir_path.display()
"Ephemeral dir removed profile={profile_id} backing={} files={files} elapsed_ms={}",
if zero { "disk_zeroed" } else { "ram" },
started.elapsed().as_millis()
);
true
}
@@ -290,7 +310,7 @@ pub fn remove_ephemeral_dir(profile_id: &str) -> bool {
// knowingly still on the machine. The mapping is kept so a later sweep
// can try again.
log::error!(
"Failed to remove ephemeral dir {} for profile {profile_id}: {e}. Profile data is still on disk.",
"Ephemeral dir removal failed; data left behind profile={profile_id} path={} err=\"{e}\"",
dir_path.display()
);
false
@@ -304,10 +324,17 @@ pub fn remove_ephemeral_dir(profile_id: &str) -> bool {
pub fn recover_ephemeral_dirs() {
cleanup_legacy_dirs();
// Resolving the base creates it, and on macOS that mounts a RAM disk that
// stays until the Mac restarts. Most installs never use an ephemeral or
// password-protected profile, so there is usually nothing to look for.
if !earlier_base_exists() {
return;
}
let (base, backing) = match get_ephemeral_base_dir() {
Ok(base) => base,
Err(e) => {
log::warn!("Cannot recover ephemeral dirs: {e}");
log::warn!("Ephemeral dir recovery failed err=\"{e}\"");
return;
}
};
@@ -341,7 +368,7 @@ pub fn recover_ephemeral_dirs() {
backing,
},
);
log::info!("Recovered ephemeral dir for profile {}", name);
log::info!("Ephemeral dir recovered profile={name}");
}
}
}
@@ -352,24 +379,68 @@ pub fn recover_ephemeral_dirs() {
/// fallback, unless that fallback is the base being used right now (in which
/// case `recover_ephemeral_dirs` is about to adopt them instead).
fn sweep_disk_fallback_residue(current_base: &Path) {
let fallback = std::env::temp_dir().join("donut-ephemeral");
let fallback = disk_fallback_base();
if !fallback.exists() || fallback == current_base {
return;
}
match crate::fs_secure::secure_remove_dir_all(&fallback, true) {
Ok(files) if files > 0 => log::info!(
"Securely erased {files} file(s) of disk-backed ephemeral residue at {}",
"Erased disk-backed ephemeral residue files={files} path={}",
fallback.display()
),
Ok(_) => {}
Err(e) => log::error!(
"Failed to erase disk-backed ephemeral residue at {}: {e}",
"Erasing disk-backed ephemeral residue failed path={} err=\"{e}\"",
fallback.display()
),
}
}
/// Whether the macOS RAM disk is mounted. `None` on other systems.
pub fn ram_disk_mounted() -> Option<bool> {
#[cfg(target_os = "macos")]
return Some(Path::new(MACOS_RAM_DISK).is_dir());
#[cfg(not(target_os = "macos"))]
None
}
/// Detach the macOS RAM disk when no profile directory is on it. Nothing else
/// detaches it, so it would stay mounted until the Mac restarts.
pub fn detach_unused_ram_disk() {
#[cfg(target_os = "macos")]
{
// Every Donut build mounts the same volume, so what is on it decides, not
// only what this run created.
if ephemeral_base_override().is_some() || !holds_no_profile_dirs(Path::new(MACOS_RAM_DISK)) {
return;
}
match std::process::Command::new("hdiutil")
.args(["detach", "-force", MACOS_RAM_DISK])
.output()
{
Ok(output) if output.status.success() => {
log::info!("RAM disk detached path={MACOS_RAM_DISK} reason=unused_at_exit");
}
Ok(output) => log::warn!(
"RAM disk detach failed path={MACOS_RAM_DISK} err=\"{}\"",
String::from_utf8_lossy(&output.stderr).trim()
),
Err(e) => log::warn!("RAM disk detach failed path={MACOS_RAM_DISK} err=\"{e}\""),
}
}
}
/// False when `path` cannot be read, so a missing volume is never detached.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn holds_no_profile_dirs(path: &Path) -> bool {
std::fs::read_dir(path).is_ok_and(|entries| {
entries
.flatten()
.all(|entry| uuid::Uuid::parse_str(&entry.file_name().to_string_lossy()).is_err())
})
}
/// Remove old-format ephemeral dirs from /tmp (pre-tmpfs migration).
fn cleanup_legacy_dirs() {
let temp_dir = std::env::temp_dir();
@@ -384,10 +455,10 @@ fn cleanup_legacy_dirs() {
// always on real disk and always worth zeroing.
if name.starts_with("donut-ephemeral-") && entry.path().is_dir() {
if let Err(e) = crate::fs_secure::secure_remove_dir_all(&entry.path(), true) {
log::warn!("Failed to clean up legacy ephemeral dir: {e}");
log::warn!("Legacy ephemeral dir cleanup failed err=\"{e}\"");
} else {
log::info!(
"Cleaned up legacy ephemeral dir: {}",
"Legacy ephemeral dir removed path={}",
entry.path().display()
);
}
@@ -521,6 +592,30 @@ mod tests {
remove_ephemeral_dir(&test_id);
}
#[test]
#[serial_test::serial]
fn recovery_creates_no_base_when_no_earlier_run_left_one() {
let base = BaseGuard::new();
let missing = base.path().join("never-created");
std::env::set_var("DONUTBROWSER_EPHEMERAL_ROOT", &missing);
recover_ephemeral_dirs();
assert!(!missing.exists());
}
#[test]
fn only_a_volume_without_profile_dirs_counts_as_unused() {
let volume = tempfile::tempdir().unwrap();
assert!(!holds_no_profile_dirs(&volume.path().join("not-mounted")));
std::fs::create_dir(volume.path().join(".fseventsd")).unwrap();
assert!(holds_no_profile_dirs(volume.path()));
std::fs::create_dir(volume.path().join(uuid::Uuid::new_v4().to_string())).unwrap();
assert!(!holds_no_profile_dirs(volume.path()));
}
#[test]
#[serial_test::serial]
fn disk_backed_dirs_are_zeroed_and_ram_backed_ones_are_not() {
+9 -3
View File
@@ -276,12 +276,15 @@ async fn download_archive(url: &str) -> Result<Vec<u8>, String> {
.send()
.await
.map_err(|e| {
log::warn!("Extension download request failed: {e}");
log::warn!("Extension download failed err=\"{}\"", e.without_url());
err("EXTENSION_DOWNLOAD_FAILED")
})?;
if !response.status().is_success() {
log::warn!("Extension download answered HTTP {}", response.status());
log::warn!(
"Extension download failed status={}",
response.status().as_u16()
);
return Err(err("EXTENSION_DOWNLOAD_FAILED"));
}
// A redirect the policy stopped surfaces here as a 3xx, which
@@ -301,7 +304,10 @@ async fn download_archive(url: &str) -> Result<Vec<u8>, String> {
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
log::warn!("Extension download stream failed: {e}");
log::warn!(
"Extension download stream failed err=\"{}\"",
e.without_url()
);
err("EXTENSION_DOWNLOAD_FAILED")
})?;
// A server is free to lie about, or omit, Content-Length, so the ceiling
+22 -21
View File
@@ -756,7 +756,7 @@ impl ExtensionManager {
fs::write(metadata_path, json)?;
if let Err(e) = events::emit_empty("extensions-changed") {
log::error!("Failed to emit extensions-changed event: {e}");
log::warn!("Event emit failed event=extensions-changed err=\"{e}\"");
}
if ext.sync_enabled {
@@ -1041,7 +1041,7 @@ impl ExtensionManager {
fs::write(metadata_path, json)?;
if let Err(e) = events::emit_empty("extensions-changed") {
log::error!("Failed to emit extensions-changed event: {e}");
log::warn!("Event emit failed event=extensions-changed err=\"{e}\"");
}
if ext.sync_enabled {
@@ -1076,7 +1076,7 @@ impl ExtensionManager {
self.save_groups_data(&groups_data)?;
if let Err(e) = events::emit_empty("extensions-changed") {
log::error!("Failed to emit extensions-changed event: {e}");
log::warn!("Event emit failed event=extensions-changed err=\"{e}\"");
}
if ext.sync_enabled {
@@ -1086,11 +1086,13 @@ impl ExtensionManager {
match crate::sync::SyncEngine::create_from_settings(&app_handle_clone).await {
Ok(engine) => {
if let Err(e) = engine.delete_extension(&ext_id).await {
log::warn!("Failed to delete extension {} from sync: {}", ext_id, e);
log::warn!("Remote extension delete failed extension={ext_id} err=\"{e}\"");
}
}
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
log::debug!(
"Remote extension delete skipped: sync not configured extension={ext_id} err=\"{e}\""
);
}
}
});
@@ -1151,7 +1153,7 @@ impl ExtensionManager {
self.save_groups_data(&data)?;
if let Err(e) = events::emit_empty("extensions-changed") {
log::error!("Failed to emit extensions-changed event: {e}");
log::warn!("Event emit failed event=extensions-changed err=\"{e}\"");
}
if group.sync_enabled {
@@ -1224,7 +1226,7 @@ impl ExtensionManager {
self.save_groups_data(&data)?;
if let Err(e) = events::emit_empty("extensions-changed") {
log::error!("Failed to emit extensions-changed event: {e}");
log::warn!("Event emit failed event=extensions-changed err=\"{e}\"");
}
if updated.sync_enabled {
@@ -1279,21 +1281,21 @@ impl ExtensionManager {
Ok(engine) => {
if let Err(e) = engine.delete_extension_group(&group_id_owned).await {
log::warn!(
"Failed to delete extension group {} from sync: {}",
group_id_owned,
e
"Remote extension group delete failed extension_group={group_id_owned} err=\"{e}\""
);
}
}
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
log::debug!(
"Remote extension group delete skipped: sync not configured extension_group={group_id_owned} err=\"{e}\""
);
}
}
});
}
if let Err(e) = events::emit_empty("extensions-changed") {
log::error!("Failed to emit extensions-changed event: {e}");
log::warn!("Event emit failed event=extensions-changed err=\"{e}\"");
}
Ok(())
@@ -1323,7 +1325,7 @@ impl ExtensionManager {
self.save_groups_data(&data)?;
if let Err(e) = events::emit_empty("extensions-changed") {
log::error!("Failed to emit extensions-changed event: {e}");
log::warn!("Event emit failed event=extensions-changed err=\"{e}\"");
}
if updated.sync_enabled {
@@ -1357,7 +1359,7 @@ impl ExtensionManager {
self.save_groups_data(&data)?;
if let Err(e) = events::emit_empty("extensions-changed") {
log::error!("Failed to emit extensions-changed event: {e}");
log::warn!("Event emit failed event=extensions-changed err=\"{e}\"");
}
if updated.sync_enabled {
@@ -1533,16 +1535,15 @@ impl ExtensionManager {
let linked_path = PathBuf::from(linked);
if !linked_path.join("manifest.json").exists() {
log::warn!(
"Skipping linked extension '{}': {} is no longer an extension folder",
ext.name,
linked
"Linked extension skipped: folder has no manifest.json extension={} path=\"{linked}\"",
ext.id
);
continue;
}
if !path_is_load_extension_safe(&linked_path) {
log::warn!(
"Skipping linked extension '{}': path contains a comma, which --load-extension cannot express",
ext.name
"Linked extension skipped: path has a comma --load-extension cannot express extension={}",
ext.id
);
continue;
}
@@ -1561,7 +1562,7 @@ impl ExtensionManager {
extension_paths.push(unpack_dir.to_string_lossy().to_string());
}
Err(e) => {
log::warn!("Failed to unpack extension '{}': {}", ext.name, e);
log::warn!("Extension unpack failed extension={} err=\"{e}\"", ext.id);
}
}
}
@@ -1582,7 +1583,7 @@ impl ExtensionManager {
let dir = Self::unpacked_dir_for_profile(profile_id);
if dir.exists() {
if let Err(e) = fs::remove_dir_all(&dir) {
log::warn!("Failed to clean staged extensions for profile {profile_id}: {e}");
log::warn!("Staged extension cleanup failed profile={profile_id} err=\"{e}\"");
}
}
}
+62 -91
View File
@@ -215,8 +215,6 @@ impl Extractor {
// If the executable is not in the expected subdirectory, create the structure
if !exe_path.starts_with(&expected_subdir) {
log::info!("Reorganizing directory structure for {}", browser_type);
// Create the expected subdirectory
std::fs::create_dir_all(&expected_subdir)?;
@@ -238,19 +236,16 @@ impl Extractor {
// Move the file/directory
if let Err(e) = std::fs::rename(&path, &target_path) {
log::info!(
"Warning: Failed to move {} to {}: {}",
log::warn!(
"Browser layout move failed from={} to={} err=\"{e}\"",
path.display(),
target_path.display(),
e
target_path.display()
);
} else {
log::info!("Moved {} to {}", path.display(), target_path.display());
}
}
}
log::info!("Directory structure reorganized for {}", browser_type);
log::debug!("Browser layout reorganized browser={browser_type}");
}
Ok(())
@@ -283,13 +278,6 @@ impl Extractor {
// report, and the frontend falls back to an indeterminate bar.
let reporter = ExtractionReporter::new(browser_type.as_str().to_string(), version.to_string());
log::info!(
"Starting extraction of {} for browser {} version {}",
archive_path.display(),
browser_type.as_str(),
version
);
// Detect the actual file type by reading the file header
let actual_format = self.detect_file_format(archive_path).map_err(|e| {
format!(
@@ -298,7 +286,10 @@ impl Extractor {
e
)
})?;
log::info!("Detected format: {actual_format}");
log::debug!(
"Extracting browser={} version={version} format={actual_format}",
browser_type.as_str()
);
let extraction_result = match actual_format.as_str() {
"dmg" => {
@@ -396,23 +387,14 @@ impl Extractor {
}
}
log::info!(
"Successfully extracted {} {} to: {}",
log::debug!(
"Extracted browser={} version={version} executable={}",
browser_type.as_str(),
version,
path.display()
);
Ok(path)
}
Err(e) => {
log::error!(
"Extraction failed for {} {}: {}",
browser_type.as_str(),
version,
e
);
Err(e)
}
Err(e) => Err(e),
}
}
@@ -523,12 +505,6 @@ impl Extractor {
dest_dir: &Path,
progress: Option<&ExtractionReporter>,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
log::info!(
"Extracting DMG: {} to {}",
dmg_path.display(),
dest_dir.display()
);
// Create a temporary mount point
let mount_point = std::env::temp_dir().join(format!(
"donut_mount_{}",
@@ -539,8 +515,6 @@ impl Extractor {
));
create_dir_all(&mount_point)?;
log::info!("Created mount point: {}", mount_point.display());
// Mount the DMG
let output = Command::new("hdiutil")
.args([
@@ -559,7 +533,7 @@ impl Extractor {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
log::error!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
log::debug!("DMG mount failed stdout={stdout:?} stderr={stderr:?}");
// Clean up mount point before returning error
let _ = fs::remove_dir_all(&mount_point);
@@ -567,7 +541,7 @@ impl Extractor {
return Err(format!("Failed to mount DMG: {stderr}").into());
}
log::info!("Successfully mounted DMG");
log::debug!("DMG mounted mount={}", mount_point.display());
// Find the .app directory in the mount point
let app_result = self.find_app_in_directory(&mount_point).await;
@@ -575,7 +549,7 @@ impl Extractor {
let app_entry = match app_result {
Ok(app_path) => app_path,
Err(e) => {
log::error!("Failed to find .app in mount point: {e}");
log::debug!("No .app in mounted DMG err=\"{e}\"");
// Try to unmount before returning error
let _ = Command::new("hdiutil")
@@ -588,13 +562,9 @@ impl Extractor {
}
};
log::info!("Found .app bundle: {}", app_entry.display());
// Copy the .app to the destination
let app_path = dest_dir.join(app_entry.file_name().unwrap());
log::info!("Copying .app to: {}", app_path.display());
// The copy is the long pole of DMG extraction; size up the source once so
// we can report real progress by polling the destination while cp runs.
// report_bytes no-ops on a 0 total, so the None path skips both walks.
@@ -634,7 +604,6 @@ impl Extractor {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::error!("Failed to copy app: {stderr}");
// Unmount before returning error
let _ = Command::new("hdiutil")
@@ -646,8 +615,6 @@ impl Extractor {
return Err(format!("Failed to copy app: {stderr}").into());
}
log::info!("Successfully copied .app bundle");
// Remove the macOS quarantine attribute so Gatekeeper doesn't block launch
// — but only if it's actually present. A no-op `removexattr` syscall on a
// signed .app bundle still trips macOS Sequoia's App Management privacy
@@ -659,9 +626,7 @@ impl Extractor {
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output()
.await;
log::info!("Removed quarantine attributes");
} else {
log::info!("No quarantine attribute on .app, skipping xattr removal");
log::debug!("Removed quarantine attribute");
}
// Unmount the DMG
@@ -672,10 +637,12 @@ impl Extractor {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Warning: Failed to unmount DMG: {stderr}");
log::warn!(
"DMG unmount failed, image stays mounted mount={} stderr={:?}",
mount_point.display(),
stderr.trim()
);
// Don't fail if unmount fails - the extraction was successful
} else {
log::info!("Successfully unmounted DMG");
}
// Clean up mount point directory
@@ -713,7 +680,7 @@ impl Extractor {
if path.is_dir() {
if let Some(extension) = path.extension() {
if extension == "app" {
log::info!("Found .app bundle at depth {}: {}", depth, path.display());
log::debug!("Found .app bundle at depth {}: {}", depth, path.display());
return Ok(path);
}
}
@@ -763,7 +730,7 @@ impl Extractor {
dest_dir: &Path,
progress: Option<&ExtractionReporter>,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
log::info!("Extracting ZIP archive: {}", zip_path.display());
log::debug!("Extracting ZIP archive: {}", zip_path.display());
std::fs::create_dir_all(dest_dir)?;
let file = File::open(zip_path)
@@ -772,7 +739,7 @@ impl Extractor {
let mut archive = zip::ZipArchive::new(BufReader::new(file))
.map_err(|e| format!("Failed to read ZIP archive {}: {}", zip_path.display(), e))?;
log::info!("ZIP archive contains {} files", archive.len());
log::debug!("ZIP archive contains {} files", archive.len());
// Total uncompressed size, known from the central directory without any
// decompression. None for archives using data descriptors — those get no
@@ -836,11 +803,10 @@ impl Extractor {
}
}
log::info!("ZIP extraction completed.");
log::debug!("ZIP extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self
.find_extracted_executable(dest_dir)
.await
@@ -853,7 +819,7 @@ impl Extractor {
dest_dir: &Path,
progress: Option<&ExtractionReporter>,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
log::info!("Extracting tar.gz archive: {}", tar_path.display());
log::debug!("Extracting tar.gz archive: {}", tar_path.display());
std::fs::create_dir_all(dest_dir)?;
let file = File::open(tar_path)?;
@@ -866,9 +832,8 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.gz extraction completed.");
log::debug!("tar.gz extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -878,7 +843,7 @@ impl Extractor {
dest_dir: &Path,
progress: Option<&ExtractionReporter>,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
log::info!("Extracting tar.bz2 archive: {}", tar_path.display());
log::debug!("Extracting tar.bz2 archive: {}", tar_path.display());
std::fs::create_dir_all(dest_dir)?;
let file = File::open(tar_path)?;
@@ -891,9 +856,8 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.bz2 extraction completed.");
log::debug!("tar.bz2 extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -903,7 +867,7 @@ impl Extractor {
dest_dir: &Path,
progress: Option<&ExtractionReporter>,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
log::info!("Extracting tar.xz archive: {}", tar_path.display());
log::debug!("Extracting tar.xz archive: {}", tar_path.display());
std::fs::create_dir_all(dest_dir)?;
let file = File::open(tar_path)?;
@@ -946,9 +910,8 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.xz extraction completed.");
log::debug!("tar.xz extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -957,7 +920,7 @@ impl Extractor {
msi_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
log::info!("Extracting MSI archive: {}", msi_path.display());
log::debug!("Extracting MSI archive: {}", msi_path.display());
std::fs::create_dir_all(dest_dir)?;
// Extract MSI in a separate scope to avoid Send issues
@@ -966,9 +929,8 @@ impl Extractor {
extractor.to(dest_dir);
}
log::info!("MSI extraction completed.");
log::debug!("MSI extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -1045,14 +1007,14 @@ impl Extractor {
let single_dir = &dirs[0];
if single_dir.extension().is_some_and(|ext| ext == "app") {
log::info!(
log::debug!(
"Skipping flatten: {} is a macOS app bundle",
single_dir.display()
);
return Ok(());
}
log::info!(
log::debug!(
"Flattening single-directory archive: moving contents of {} to {}",
single_dir.display(),
dest_dir.display()
@@ -1085,7 +1047,7 @@ impl Extractor {
)
})?;
log::info!("Successfully flattened archive directory structure");
log::debug!("Successfully flattened archive directory structure");
}
Ok(())
@@ -1127,7 +1089,7 @@ impl Extractor {
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
log::info!("Searching for .app bundle in: {}", dest_dir.display());
log::debug!("Searching for .app bundle in: {}", dest_dir.display());
// Use the enhanced recursive search
match self.find_app_in_directory(dest_dir).await {
@@ -1135,7 +1097,7 @@ impl Extractor {
// Check if the app is in a subdirectory and move it to the root if needed
let app_parent = app_path.parent().unwrap();
if app_parent != dest_dir {
log::info!(
log::debug!(
"Found .app in subdirectory, moving to root: {} -> {}",
app_path.display(),
dest_dir.display()
@@ -1152,15 +1114,15 @@ impl Extractor {
}
}
log::info!("Successfully moved .app to: {}", target_path.display());
log::debug!("Successfully moved .app to: {}", target_path.display());
Ok(target_path)
} else {
log::info!("Found .app at root level: {}", app_path.display());
log::debug!("Found .app at root level: {}", app_path.display());
Ok(app_path)
}
}
Err(e) => {
log::info!("Failed to find .app bundle: {e}");
log::debug!("Failed to find .app bundle: {e}");
Err("No .app found after extraction".into())
}
}
@@ -1171,7 +1133,7 @@ impl Extractor {
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
log::info!(
log::debug!(
"Searching for Windows executable in: {}",
dest_dir.display()
);
@@ -1184,7 +1146,7 @@ impl Extractor {
for exe_name in &priority_exe_names {
let exe_path = dest_dir.join(exe_name);
if exe_path.exists() {
log::info!("Found priority executable: {}", exe_path.display());
log::debug!("Found priority executable: {}", exe_path.display());
return Ok(exe_path);
}
}
@@ -1192,7 +1154,7 @@ impl Extractor {
// Recursively search for executables with depth limit
match self.find_windows_executable_recursive(dest_dir, 0, 3).await {
Ok(exe_path) => {
log::info!(
log::debug!(
"Found executable via recursive search: {}",
exe_path.display()
);
@@ -1287,7 +1249,7 @@ impl Extractor {
&self,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
log::info!("Searching for Linux executable in: {}", dest_dir.display());
log::debug!("Searching for Linux executable in: {}", dest_dir.display());
// Enhanced list of common browser executable names, Wayfern first since it
// is the current name. Chrome/Chromium cover builds extracted before the
@@ -1308,7 +1270,7 @@ impl Extractor {
for exe_name in &exe_names {
let exe_path = dest_dir.join(exe_name);
if exe_path.exists() && self.is_executable(&exe_path) {
log::info!("Found executable at root level: {}", exe_path.display());
log::debug!("Found executable at root level: {}", exe_path.display());
return Ok(exe_path);
}
}
@@ -1343,7 +1305,7 @@ impl Extractor {
for exe_name in &exe_names {
let exe_path = subdir_path.join(exe_name);
if exe_path.exists() && self.is_executable(&exe_path) {
log::info!("Found executable in subdirectory: {}", exe_path.display());
log::debug!("Found executable in subdirectory: {}", exe_path.display());
return Ok(exe_path);
}
}
@@ -1356,7 +1318,7 @@ impl Extractor {
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.ends_with(".AppImage") && self.is_executable(&path) {
log::info!("Found AppImage: {}", path.display());
log::debug!("Found AppImage: {}", path.display());
return Ok(path);
}
}
@@ -1364,15 +1326,15 @@ impl Extractor {
}
// Last resort: recursive search for any executable file
log::info!("Performing recursive search for executables...");
log::debug!("Performing recursive search for executables...");
match self.find_any_executable_recursive(dest_dir, 0).await {
Ok(path) => {
log::info!("Found executable via recursive search: {}", path.display());
log::debug!("Found executable via recursive search: {}", path.display());
Ok(path)
}
Err(e) => {
// List all files in the directory for debugging
log::info!("Failed to find executable. Directory contents:");
let mut listed = Vec::new();
if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries.flatten() {
let path = entry.path();
@@ -1381,9 +1343,18 @@ impl Extractor {
} else {
false
};
log::info!(" {} (executable: {})", path.display(), is_exec);
let name = path.file_name().unwrap_or_default().to_string_lossy();
listed.push(if is_exec {
format!("{name}*")
} else {
name.to_string()
});
}
}
log::warn!(
"No executable after extraction dir={} entries={listed:?}",
dest_dir.display()
);
Err(
format!(
"No executable found in {} after extraction. Original error: {}",
@@ -1480,7 +1451,7 @@ impl Extractor {
|| name_lower.contains("wayfern")
|| file_name.ends_with(".AppImage")
{
log::info!(
log::debug!(
"Found priority executable at depth {}: {}",
depth,
path.display()
@@ -1522,7 +1493,7 @@ impl Extractor {
a_name.len().cmp(&b_name.len())
});
log::info!(
log::debug!(
"Found potential executable at depth {}: {}",
depth,
potential_executables[0].display()
+8 -2
View File
@@ -429,7 +429,10 @@ pub async fn probe_and_check_consistency(
// exit IP). Skip rather than warn on an unknown location — the same
// database gates fingerprint geo, so there's nothing to disagree with.
Err(e) => {
log::debug!("Consistency check: could not geolocate exit IP: {e}");
log::debug!(
"Consistency check skipped, exit not geolocated err=\"{}\"",
crate::log_redaction::text(&e.to_string())
);
Ok(ConsistencyResult::skip())
}
}
@@ -456,7 +459,10 @@ pub async fn probe_direct_and_check(profile: &BrowserProfile) -> Result<Consiste
Some(exit_ip),
)),
Err(e) => {
log::debug!("Consistency check: could not geolocate direct exit IP: {e}");
log::debug!(
"Consistency check skipped, direct exit not geolocated err=\"{}\"",
crate::log_redaction::text(&e.to_string())
);
Ok(ConsistencyResult::skip())
}
}
+4 -1
View File
@@ -97,7 +97,10 @@ fn remove_tree(dir: &Path, zero: bool, removed: &mut u64) {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
log::warn!("Secure erase could not read {}: {e}", dir.display());
log::warn!(
"Secure erase could not read dir path=\"{}\" err=\"{e}\"",
dir.display()
);
return;
}
};
+5 -2
View File
@@ -127,7 +127,7 @@ impl GeoIPDownloader {
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
log::info!("GeoIP database download already in progress, skipping");
log::debug!("GeoIP download already running");
return Ok(());
}
let result = self.download_geoip_database_inner(_app_handle).await;
@@ -279,7 +279,10 @@ impl GeoIPDownloader {
// the download that fingerprint geolocation actually depends on.
if let Some(url) = asn_url {
if let Err(e) = self.download_asn_database(&url).await {
log::warn!("Failed to download the GeoIP ASN database: {e}");
log::warn!(
"GeoIP ASN database download failed err=\"{}\"",
crate::log_redaction::text(&e.to_string())
);
}
}
+2 -2
View File
@@ -56,7 +56,7 @@ pub fn locale_selector() -> Option<&'static LocaleSelector> {
.get_or_init(|| match LocaleSelector::new() {
Ok(s) => Some(s),
Err(e) => {
log::warn!("Failed to build the CLDR locale selector: {e}");
log::warn!("CLDR locale selector build failed err=\"{e}\"");
None
}
})
@@ -163,7 +163,7 @@ impl LocaleSelector {
}
Ok(Event::Eof) => break,
Err(e) => {
log::warn!("Error parsing territory XML: {}", e);
log::warn!("Territory XML parse failed err=\"{e}\"");
break;
}
_ => {}
+3 -3
View File
@@ -597,13 +597,13 @@ pub fn sync_profile(profile: &BrowserProfile) -> Result<bool, String> {
/// browser process exists and while the profile is provably not running.
pub fn sync_for_launch(profile: &BrowserProfile) {
match sync_profile(profile) {
Ok(true) => log::info!("Wrote group bookmarks into profile {}", profile.name),
Ok(true) => log::info!("Group bookmarks written profile={}", profile.id),
Ok(false) => {}
// Never blocks a launch. The browser opening without today's shared
// bookmarks is a smaller failure than the browser not opening.
Err(e) => log::warn!(
"Could not write group bookmarks for profile {}: {e}",
profile.name
"Group bookmarks write failed profile={} err=\"{e}\"",
profile.id
),
}
}
+9 -7
View File
@@ -122,7 +122,7 @@ impl GroupManager {
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("groups-changed") {
log::error!("Failed to emit groups-changed event: {e}");
log::warn!("Event emit failed event=groups-changed err=\"{e}\"");
}
if group.sync_enabled {
@@ -180,7 +180,7 @@ impl GroupManager {
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("groups-changed") {
log::error!("Failed to emit groups-changed event: {e}");
log::warn!("Event emit failed event=groups-changed err=\"{e}\"");
}
if updated_group.sync_enabled {
@@ -284,13 +284,15 @@ impl GroupManager {
match crate::sync::SyncEngine::create_from_settings(&app_handle_clone).await {
Ok(engine) => {
if let Err(e) = engine.delete_group(&group_id_owned).await {
log::warn!("Failed to delete group {} from sync: {}", group_id_owned, e);
log::warn!("Remote group delete failed group={group_id_owned} err=\"{e}\"");
} else {
log::info!("Group {} deleted from S3 sync storage", group_id_owned);
log::info!("Remote group deleted group={group_id_owned}");
}
}
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
log::debug!(
"Remote group delete skipped: sync not configured group={group_id_owned} err=\"{e}\""
);
}
}
});
@@ -298,7 +300,7 @@ impl GroupManager {
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("groups-changed") {
log::error!("Failed to emit groups-changed event: {e}");
log::warn!("Event emit failed event=groups-changed err=\"{e}\"");
}
Ok(())
@@ -330,7 +332,7 @@ impl GroupManager {
self.save_groups_data(&groups_data)?;
if let Err(e) = events::emit_empty("groups-changed") {
log::error!("Failed to emit groups-changed event: {e}");
log::warn!("Event emit failed event=groups-changed err=\"{e}\"");
}
if updated_group.sync_enabled {
+20 -21
View File
@@ -151,23 +151,19 @@ fn mismatch_error(result: &ConsistencyResult, token: &str) -> String {
/// either, so the launch says what it could not check.
fn report_consistency(profile: &BrowserProfile, result: &ConsistencyResult) {
if result.is_verified() {
log::debug!(
"Fingerprint gate: {} agrees with its exit on every dimension",
profile.name
);
log::debug!("Fingerprint gate passed profile={}", profile.id);
return;
}
if result.unverified.is_empty() {
return;
}
log::warn!(
"Fingerprint gate: {} reached its exit but could not verify {}; \
the fingerprint declares no value to compare against",
profile.name,
result.unverified.join(", ")
log::info!(
"Fingerprint gate could not verify, fingerprint declares no value profile={} unverified={}",
profile.id,
result.unverified.join(",")
);
if let Err(e) = crate::events::emit("fingerprint-consistency-unverified", result) {
log::warn!("Failed to emit fingerprint consistency notice: {e}");
log::warn!("Event emit failed event=fingerprint-consistency-unverified err=\"{e}\"");
}
}
@@ -242,8 +238,9 @@ async fn enforce_direct_exit(
Ok(result) => result,
Err(e) => {
log::warn!(
"Fingerprint gate: direct exit probe failed for profile {}, allowing launch: {e}",
profile.name
"Fingerprint gate direct exit probe failed, launch allowed profile={} err=\"{}\"",
profile.id,
crate::log_redaction::text(&e.to_string())
);
return Ok(());
}
@@ -292,9 +289,10 @@ pub async fn enforce_fingerprint_gate(
let key = fingerprint_consistency::exit_cache_key(profile);
if key.is_none() || upstream.is_none() {
log::warn!(
"Fingerprint gate: {} declares a proxy/VPN that yielded no usable upstream; \
measuring the direct exit it will actually use",
profile.name
"Profile route gave no usable upstream, gate measures the direct exit profile={} proxy={} vpn={}",
profile.id,
profile.proxy_id.as_deref().unwrap_or("none"),
profile.vpn_id.as_deref().unwrap_or("none")
);
return enforce_direct_exit(profile, gate).await;
}
@@ -320,8 +318,9 @@ pub async fn enforce_fingerprint_gate(
Ok(result) => result,
Err(e) => {
log::warn!(
"Fingerprint gate: exit probe failed for profile {}, allowing launch: {e}",
profile.name
"Fingerprint gate exit probe failed, launch allowed profile={} err=\"{}\"",
profile.id,
crate::log_redaction::text(&e.to_string())
);
return Ok(());
}
@@ -343,12 +342,12 @@ pub async fn enforce_fingerprint_gate(
// `proxy` permission silently disarmed the gate for good.
if matches!(gate, FingerprintGate::Advisory) {
log::warn!(
"Fingerprint gate: {} launching with a known exit mismatch ({})",
profile.name,
result.mismatches.join(", ")
"Launching with a known fingerprint exit mismatch profile={} mismatches={}",
profile.id,
result.mismatches.join(",")
);
if let Err(e) = crate::events::emit("fingerprint-consistency-warning", &result) {
log::warn!("Failed to emit fingerprint consistency warning: {e}");
log::warn!("Event emit failed event=fingerprint-consistency-warning err=\"{e}\"");
}
return Ok(());
}
+4 -4
View File
@@ -44,7 +44,7 @@ pub fn load() -> LaunchGatePrefs {
return LaunchGatePrefs::default();
};
serde_json::from_str(&content).unwrap_or_else(|e| {
log::warn!("Failed to parse launch gate prefs, ignoring them: {e}");
log::warn!("Launch gate prefs parse failed, ignoring them err=\"{e}\"");
LaunchGatePrefs::default()
})
}
@@ -53,17 +53,17 @@ fn save(prefs: &LaunchGatePrefs) {
let path = prefs_file();
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create launch gate prefs dir: {e}");
log::warn!("Launch gate prefs dir create failed err=\"{e}\"");
return;
}
}
match serde_json::to_string_pretty(prefs) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
log::warn!("Failed to write launch gate prefs: {e}");
log::warn!("Launch gate prefs write failed err=\"{e}\"");
}
}
Err(e) => log::warn!("Failed to serialize launch gate prefs: {e}"),
Err(e) => log::warn!("Launch gate prefs serialize failed err=\"{e}\""),
}
}
+131
View File
@@ -0,0 +1,131 @@
//! The autostart entry that 0.14 to 0.17 installed for the old tray daemon.
//!
//! 0.18 to 0.24 removed it at launch. 0.25 deleted the daemon and that cleanup
//! with it, so a machine that went from 0.17 or earlier straight to 0.25 or
//! later still carries an entry that points at a binary no longer shipped. On
//! macOS that entry is a LaunchAgent, and Login Items lists Donut as running in
//! the background long after the app has quit.
use std::io;
use std::path::Path;
/// Remove the old daemon's autostart entry, if this machine still has one.
pub fn remove() {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
#[cfg(target_os = "macos")]
let entry = dirs::home_dir().map(|home| {
home
.join("Library/LaunchAgents")
.join(format!("{LAUNCH_AGENT_LABEL}.plist"))
});
#[cfg(target_os = "linux")]
let entry = dirs::config_dir().map(|config| config.join("autostart/donut-daemon.desktop"));
if let Some(path) = entry {
match remove_entry(&path, unload) {
Ok(true) => log::info!("Legacy daemon autostart removed path={}", path.display()),
Ok(false) => {}
Err(e) => log::warn!(
"Legacy daemon autostart removal failed path={} err=\"{e}\"",
path.display()
),
}
}
}
#[cfg(target_os = "windows")]
{
use winreg::enums::{HKEY_CURRENT_USER, KEY_QUERY_VALUE, KEY_SET_VALUE};
use winreg::RegKey;
let Ok(run) = RegKey::predef(HKEY_CURRENT_USER).open_subkey_with_flags(
r"Software\Microsoft\Windows\CurrentVersion\Run",
KEY_QUERY_VALUE | KEY_SET_VALUE,
) else {
return;
};
match run.delete_value("DonutBrowserDaemon") {
Ok(()) => log::info!("Legacy daemon autostart removed key=HKCU\\Run\\DonutBrowserDaemon"),
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => log::warn!(
"Legacy daemon autostart removal failed key=HKCU\\Run\\DonutBrowserDaemon err=\"{e}\""
),
}
}
}
#[cfg(target_os = "macos")]
const LAUNCH_AGENT_LABEL: &str = "com.donutbrowser.daemon";
/// Deleting the plist alone leaves the job loaded until the next login.
#[cfg(target_os = "macos")]
fn unload() {
let target = format!("gui/{}/{LAUNCH_AGENT_LABEL}", unsafe { libc::getuid() });
match std::process::Command::new("launchctl")
.args(["bootout", &target])
.output()
{
Ok(output) if output.status.success() => {
log::info!("Legacy daemon LaunchAgent unloaded target={target}");
}
Ok(_) => {}
Err(e) => log::warn!("launchctl bootout failed target={target} err=\"{e}\""),
}
}
#[cfg(target_os = "linux")]
fn unload() {}
/// Delete the entry at `path`, calling `unload` first. `Ok(false)` when there
/// is no entry, in which case `unload` is not called.
#[cfg_attr(target_os = "windows", allow(dead_code))]
fn remove_entry(path: &Path, unload: impl FnOnce()) -> io::Result<bool> {
if path.symlink_metadata().is_err() {
return Ok(false);
}
unload();
std::fs::remove_file(path)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::remove_entry;
use std::cell::Cell;
#[test]
fn an_entry_is_unloaded_then_deleted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("com.donutbrowser.daemon.plist");
std::fs::write(&path, "<plist/>").unwrap();
let unloaded = Cell::new(false);
let removed = remove_entry(&path, || {
assert!(path.exists(), "unload runs while the entry still exists");
unloaded.set(true);
})
.unwrap();
assert!(removed);
assert!(unloaded.get());
assert!(!path.exists());
}
#[test]
fn a_missing_entry_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
let unloaded = Cell::new(false);
let removed = remove_entry(&dir.path().join("donut-daemon.desktop"), || {
unloaded.set(true)
})
.unwrap();
assert!(!removed);
assert!(
!unloaded.get(),
"nothing is unloaded when there is no entry"
);
}
}
+153 -130
View File
@@ -34,6 +34,25 @@ pub(crate) fn vless_config_error(error: &crate::xray::XrayError) -> String {
.to_string()
}
/// `donutbrowser_lib::sync::scheduler` -> `sync::scheduler`, `lib.rs` itself
/// -> `app`, and `webview::<script url:line:col>` -> `webview`. The crate name
/// and the logging call's own location are on every line and say nothing.
fn short_log_target(target: &str) -> &str {
if target.starts_with(tauri_plugin_log::WEBVIEW_TARGET) {
return tauri_plugin_log::WEBVIEW_TARGET;
}
match target.strip_prefix("donutbrowser_lib") {
Some("") => "app",
Some(rest) => rest.strip_prefix("::").unwrap_or(target),
None => target,
}
}
static PROXY_CLEANUP: log_streak::Streak =
log_streak::Streak::new(module_path!(), "Dead-browser proxy cleanup");
static STATUS_CHECK: log_streak::Streak =
log_streak::Streak::new(module_path!(), "Browser status check");
fn e2e_automation_enabled() -> bool {
#[cfg(feature = "e2e")]
{
@@ -82,8 +101,11 @@ mod human_typing;
mod ip_utils;
mod launch_gate;
mod launch_gate_prefs;
mod legacy_autostart;
mod log_redaction;
pub mod log_streak;
mod platform_browser;
mod process_report;
mod profile;
mod profile_generation_limiter;
mod profile_import;
@@ -342,12 +364,10 @@ fn headless_automation() -> bool {
// Called internally for deep-link / startup URL handling — not invoked from the
// frontend, so it is intentionally not a `#[tauri::command]`.
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
log::info!("Handling URL open request");
log::debug!("Opening URL from the OS");
// Check if the main window exists and is ready
if let Some(window) = app.get_webview_window("main") {
log::debug!("Main window exists");
// Try to show and focus the window first. Skip under a headless automation
// driver so an e2e run never steals the user's focus.
if !headless_automation() {
@@ -360,7 +380,6 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
} else {
// Window doesn't exist yet - add to pending URLs
log::debug!("Main window doesn't exist, adding URL to pending list");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url);
}
@@ -920,7 +939,7 @@ async fn rotate_mcp_remote_credential() -> Result<McpRemoteCredentialRotation, S
Ok(grant) => grant,
Err(e) if is_mcp_key_limit(&e) => match previous.as_deref() {
Some(id) => {
log::info!("[mcp-remote] At the credential cap; retiring {id} before minting again");
log::info!("Retiring MCP credential at the cap id={id}");
cloud_auth::CLOUD_AUTH.revoke_mcp_key(id).await?;
cloud_auth::CLOUD_AUTH.create_mcp_key(&label).await?
}
@@ -937,12 +956,12 @@ async fn rotate_mcp_remote_credential() -> Result<McpRemoteCredentialRotation, S
// Best effort: the new key is already stored and installed below, and a
// key that outlives its replacement is visible on the account page.
if let Err(e) = cloud_auth::CLOUD_AUTH.revoke_mcp_key(&old).await {
log::warn!("[mcp-remote] Could not revoke the replaced credential {old}: {e}");
log::warn!("Revoking the replaced MCP credential failed id={old} err=\"{e}\"");
}
}
log::info!(
"[mcp-remote] Rotated the remote MCP credential to {}",
"Rotated MCP credential key_prefix={}",
mcp_key_display_prefix(&grant.key)
);
// Every client whose entry points at the remote endpoint is rewritten with
@@ -980,10 +999,10 @@ async fn forget_mcp_remote_credential() -> Result<(), String> {
cloud_auth::CLOUD_AUTH.revoke_mcp_key(&id).await?;
}
Some(id) => log::warn!(
"[mcp-remote] Forgetting credential {id} while signed out; revoke it from the account page"
"Forgot MCP credential while signed out id={id}; it stays valid until revoked on the account page"
),
None => log::warn!(
"[mcp-remote] Forgetting a credential with no stored id; revoke it from the account page"
"Forgot MCP credential while signed out id=unknown; it stays valid until revoked on the account page"
),
}
@@ -1331,14 +1350,14 @@ fn reinstall_mcp_agents() -> Vec<String> {
let target = match mcp_target() {
Ok(target) => target,
Err(e) => {
log::warn!("Could not resolve the MCP target, so no client was refreshed: {e}");
log::warn!("MCP client refresh skipped: target unresolved err=\"{e}\"");
return agents;
}
};
let mut failed = Vec::new();
for agent_id in agents {
if let Err(e) = install_mcp_agent(&agent_id, &target) {
log::warn!("Could not refresh the MCP entry for {agent_id}: {e}");
log::warn!("MCP client entry refresh failed agent={agent_id} err=\"{e}\"");
failed.push(agent_id);
}
}
@@ -1507,9 +1526,9 @@ async fn delete_vpn_config(app_handle: tauri::AppHandle, vpn_id: String) -> Resu
match sync::SyncEngine::create_from_settings(&app_handle_clone).await {
Ok(engine) => {
if let Err(e) = engine.delete_vpn(&vpn_id_clone).await {
log::warn!("Failed to delete VPN {} from sync: {}", vpn_id_clone, e);
log::warn!("VPN remote delete failed vpn={vpn_id_clone} err=\"{e}\"");
} else {
log::info!("VPN {} deleted from sync storage", vpn_id_clone);
log::info!("VPN deleted from sync vpn={vpn_id_clone}");
}
}
Err(e) => {
@@ -1621,6 +1640,7 @@ pub async fn check_vpn_validity_core(
);
let mut result = None;
let mut last_error = String::new();
for attempt in 0..3 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
@@ -1648,11 +1668,11 @@ pub async fn check_vpn_validity_core(
break;
}
Err(error) => {
log::warn!(
"VPN validation attempt {} failed to fetch public IP through donut-proxy: {}",
attempt + 1,
error
log::debug!(
"VPN check attempt failed attempt={} err=\"{error}\"",
attempt + 1
);
last_error = error.to_string();
}
}
}
@@ -1661,6 +1681,9 @@ pub async fn check_vpn_validity_core(
if !had_existing_worker {
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
}
if result.is_none() {
log::warn!("VPN check failed vpn={vpn_id} attempts=3 err=\"{last_error}\"");
}
let result = result.unwrap_or(crate::proxy_manager::ProxyCheckResult {
ip: String::new(),
@@ -1886,7 +1909,7 @@ async fn generate_sample_fingerprint(
/// untranslated if it is surfaced as-is. The raw text is kept in the app log,
/// where support can read it, and never in the toast.
fn remote_session_error(context: &str, err: remote_session::RemoteSessionError) -> String {
log::warn!("Remote session {context} failed: {err}");
log::warn!("Remote session {context} failed err=\"{err}\"");
err.to_error_json()
}
@@ -1970,8 +1993,15 @@ fn get_remote_session_events_status() -> bool {
/// Turn a cookie-bot failure into the code the frontend translates.
fn cookie_bot_error(context: &str, err: cookie_bot::CookieBotError) -> String {
log::warn!(
"Cookie bot {context} failed: {} (HTTP {})",
// Signed out is a state the page shows, not a fault.
let level = if err.code() == "CLOUD_NOT_SIGNED_IN" {
log::Level::Debug
} else {
log::Level::Warn
};
log::log!(
level,
"Cookie bot {context} failed code={} http={}",
err.code(),
err.status()
);
@@ -2303,7 +2333,6 @@ pub fn run_with_builder(
let startup_url = urls_from_args(args.iter()).into_iter().next();
if let Some(url) = startup_url.clone() {
log::info!("Found startup URL in command line");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url.clone());
}
@@ -2347,7 +2376,7 @@ pub fn run_with_builder(
out.finish(format_args!(
"[{}][{}][{}] {}",
timestamp,
record.target(),
short_log_target(record.target()),
record.level(),
message
))
@@ -2358,7 +2387,10 @@ pub fn run_with_builder(
#[cfg(not(feature = "e2e"))]
let builder = builder.plugin(tauri_plugin_single_instance::init(
|app_handle, args, _cwd| {
log::info!("Single instance triggered with args: {args:?}");
log::info!(
"Second launch forwarded args={}",
args.len().saturating_sub(1)
);
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
@@ -2374,7 +2406,7 @@ pub fn run_with_builder(
let handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = handle_url_open(handle, url).await {
log::error!("Failed to handle a forwarded URL: {e}");
log::error!("Forwarded URL failed err=\"{e}\"");
}
});
}
@@ -2407,7 +2439,7 @@ pub fn run_with_builder(
if let Some(path) = app_dirs::window_state_path_override() {
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create the window-state directory: {e}");
log::warn!("Window-state directory create failed err=\"{e}\"");
}
}
window_state = window_state.with_filename(path.to_string_lossy().into_owned());
@@ -2428,6 +2460,24 @@ pub fn run_with_builder(
);
builder.setup(|app| {
// The home folder carries the OS user name, and this line is in every
// log someone attaches to an issue.
let data_dir = app_dirs::data_dir();
let data_dir = dirs::home_dir()
.and_then(|home| data_dir.strip_prefix(home).ok())
.map(|rest| format!("~/{}", rest.display()))
.unwrap_or_else(|| data_dir.display().to_string());
log::info!(
"Donut started version={} os=\"{}\" arch={} pid={} tz={} portable={} data_dir={}",
env!("BUILD_VERSION"),
sysinfo::System::long_os_version().unwrap_or_else(|| std::env::consts::OS.to_string()),
std::env::consts::ARCH,
std::process::id(),
chrono::Local::now().format("%:z"),
app_dirs::is_portable(),
data_dir
);
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
@@ -2521,7 +2571,7 @@ pub fn run_with_builder(
#[cfg(not(feature = "e2e"))]
{
if let Err(e) = setup_system_tray(app.handle()) {
log::warn!("System tray unavailable, continuing without it: {e}");
log::warn!("System tray unavailable err=\"{e}\"");
}
}
@@ -2537,7 +2587,7 @@ pub fn run_with_builder(
}
api.prevent_close();
if let Err(e) = app_handle.emit("close-confirm-requested", ()) {
log::warn!("Failed to emit close-confirm-requested: {e}");
log::warn!("Emit close-confirm-requested failed err=\"{e}\"");
}
}
});
@@ -2553,7 +2603,7 @@ pub fn run_with_builder(
#[cfg(target_os = "linux")]
{
log::info!(
"Linux window decorations: server-side = {:?}",
"Linux window decorations server_side={:?}",
window.is_decorated()
);
@@ -2573,10 +2623,10 @@ pub fn run_with_builder(
.unwrap_or(false);
if window_decorations::use_client_side_decorations() && !has_saved_geometry {
if let Err(e) = window.set_size(tauri::LogicalSize::new(880.0, 500.0)) {
log::warn!("Failed to re-apply the window size after dropping decorations: {e}");
log::warn!("Window resize after dropping decorations failed err=\"{e}\"");
}
if let Err(e) = window.center() {
log::warn!("Failed to re-center the window after dropping decorations: {e}");
log::warn!("Window re-center after dropping decorations failed err=\"{e}\"");
}
}
}
@@ -2586,7 +2636,7 @@ pub fn run_with_builder(
// entering immersive native fullscreen.
#[cfg(target_os = "macos")]
if let Err(e) = window.disable_native_fullscreen() {
log::warn!("Failed to disable native fullscreen: {e}");
log::warn!("Disabling native fullscreen failed err=\"{e}\"");
}
// Set up deep link handler
@@ -2595,14 +2645,14 @@ pub fn run_with_builder(
// Initialize the global event emitter for the events module
let emitter = std::sync::Arc::new(events::TauriEmitter::new(handle.clone()));
if let Err(e) = events::set_global_emitter(emitter) {
log::warn!("Failed to set global event emitter: {e}");
log::warn!("Setting the global event emitter failed err=\"{e}\"");
}
#[cfg(all(windows, not(feature = "e2e")))]
{
// For Windows, register all deep links at runtime
if let Err(e) = app.deep_link().register_all() {
log::warn!("Failed to register deep links: {e}");
log::warn!("Deep link registration failed err=\"{e}\"");
}
}
@@ -2622,16 +2672,15 @@ pub fn run_with_builder(
let handle = handle.clone();
move |event| {
let urls = event.urls();
log::info!("Deep link event received with {} URLs", urls.len());
log::info!("Deep link received urls={}", urls.len());
for url in urls {
let url_string = url.to_string();
log::info!("Processing deep link URL");
let handle_clone = handle.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
log::error!("Failed to handle deep link URL: {e}");
log::error!("Deep link failed err=\"{e}\"");
}
});
}
@@ -2642,9 +2691,8 @@ pub fn run_with_builder(
if let Some(startup_url) = startup_url {
let handle_clone = handle.clone();
tauri::async_runtime::spawn(async move {
log::info!("Processing startup URL from command line");
if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await {
log::error!("Failed to handle startup URL: {e}");
log::error!("Startup URL failed err=\"{e}\"");
}
});
}
@@ -2663,7 +2711,7 @@ pub fn run_with_builder(
{
let updater_guard = version_updater.lock().await;
if let Err(e) = updater_guard.start_background_updates().await {
log::error!("Failed to start background updates: {e}");
log::error!("Background version updates failed to start err=\"{e}\"");
}
}
});
@@ -2698,18 +2746,18 @@ pub fn run_with_builder(
cloud_auth::ensure_remote_bridge(&bridge_handle).await;
if !mcp_remote::is_running() {
log::info!(
"MCP remote control is enabled in settings but nobody is signed in; the bridge opens on sign-in"
"MCP remote control enabled; bridge waits for sign-in"
);
}
});
} else {
log::info!(
"MCP remote control is DISABLED in settings (mcp_remote_enabled=false). This browser cannot be driven from donutbrowser.com until it's enabled in Settings → Integrations."
"MCP remote control disabled mcp_remote_enabled=false"
);
}
}
Err(e) => {
log::warn!("Could not read settings to determine MCP state: {e}");
log::warn!("Reading settings for MCP state failed err=\"{e}\"");
}
}
}
@@ -2726,11 +2774,7 @@ pub fn run_with_builder(
if let Some(pid) = profile.process_id {
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
if system.process(sysinfo_pid).is_none() {
log::info!(
"Clearing stale process_id {} for profile {}",
pid,
profile.name
);
log::info!("Cleared stale browser pid profile={} pid={pid}", profile.id);
let mut updated = profile.clone();
updated.process_id = None;
let _ = profile_manager.save_profile(&updated);
@@ -2740,6 +2784,10 @@ pub fn run_with_builder(
}
}
tauri::async_runtime::spawn_blocking(legacy_autostart::remove);
log::info!("Found from an earlier run {}", process_report::summary());
// Kill orphaned proxy and VPN worker processes from previous app runs.
// Since active_proxies is an in-memory map that starts empty, any running
// donut-proxy workers on disk must be orphans the current app can't track.
@@ -2785,21 +2833,14 @@ pub fn run_with_builder(
.is_some_and(|pid| running_profile_ids.contains(pid))
};
if has_running_browser {
log::info!(
"Startup: preserving proxy worker {} (browser still running)",
config.id
);
continue;
}
if let Some(pid) = config.pid {
if is_process_running(pid) {
log::info!(
"Startup: killing orphaned proxy worker {} (PID {})",
config.id,
pid
);
let _ = crate::proxy_runner::stop_proxy_process(&config.id).await;
let _ =
crate::proxy_runner::stop_proxy_process_because(&config.id, "orphan_at_startup")
.await;
continue;
}
}
@@ -2808,20 +2849,11 @@ pub fn run_with_builder(
for worker in list_vpn_worker_configs() {
if running_vpn_ids.contains(&worker.vpn_id) {
log::info!(
"Startup: preserving VPN worker {} (profile browser using vpn_id {} still running)",
worker.id,
worker.vpn_id
);
continue;
}
if crate::vpn_worker_runner::vpn_worker_alive(&worker) {
log::info!(
"Startup: killing orphaned VPN worker {} (PID {:?})",
worker.id,
worker.pid
);
// stop_vpn_worker writes the stop line.
let _ = crate::vpn_worker_runner::stop_vpn_worker(&worker.id).await;
continue;
}
@@ -2839,14 +2871,13 @@ pub fn run_with_builder(
Ok(updated) => {
if !updated.is_empty() {
log::info!(
"Startup: bumped {} profiles to latest installed versions: {:?}",
updated.len(),
updated
"Moved profiles to the latest installed browser count={}",
updated.len()
);
}
}
Err(e) => {
log::error!("Startup: failed to bump profiles to latest installed versions: {e}");
log::error!("Moving profiles to the latest installed browser failed err=\"{e}\"");
}
}
wayfern_identity_storage::request_conversion_pass();
@@ -2873,9 +2904,8 @@ pub fn run_with_builder(
};
for url in pending_urls {
log::info!("Processing pending URL");
if let Err(e) = handle_url_open(handle_pending.clone(), url).await {
log::error!("Failed to handle pending URL: {e}");
log::error!("Pending URL failed err=\"{e}\"");
}
}
});
@@ -2891,7 +2921,7 @@ pub fn run_with_builder(
tauri::async_runtime::spawn(async move {
let swept = profile::ProfileManager::instance().sweep_temporary_profiles(&handle);
if swept > 0 {
log::info!("Swept {swept} temporary profile(s) left by an earlier run");
log::info!("Swept temporary profiles from an earlier run count={swept}");
}
});
}
@@ -2912,9 +2942,7 @@ pub fn run_with_builder(
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::error!("Periodic cleanup failed: {e}");
} else {
log::debug!("Periodic cleanup completed successfully");
log::error!("Unused browser binary cleanup failed err=\"{e}\"");
}
}
});
@@ -2933,20 +2961,11 @@ pub fn run_with_builder(
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
loop {
interval.tick().await;
log::info!("Checking for app updates...");
match app_auto_updater::check_for_app_updates().await {
Ok(Some(update_info)) => {
log::info!(
"App update available: {} -> {}",
update_info.current_version,
update_info.new_version
);
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Failed to emit app update event: {e}");
}
// check_for_app_updates logs its own result and failure streak.
if let Ok(Some(update_info)) = app_auto_updater::check_for_app_updates().await {
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Emit app-update-available failed err=\"{e}\"");
}
Ok(None) => log::debug!("No app updates available"),
Err(e) => log::error!("Failed to check for app updates: {e}"),
}
}
});
@@ -2957,21 +2976,21 @@ pub fn run_with_builder(
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
match geoip_downloader.check_missing_geoip_database() {
Ok(true) => {
log::info!(
"GeoIP database is missing for Wayfern profiles, downloading at startup..."
);
let started = std::time::Instant::now();
let geoip_downloader = GeoIPDownloader::instance();
if let Err(e) = geoip_downloader
match geoip_downloader
.download_geoip_database(&app_handle_geoip)
.await
{
log::error!("Failed to download GeoIP database at startup: {e}");
} else {
log::info!("GeoIP database downloaded successfully at startup");
Ok(_) => log::info!(
"GeoIP database downloaded reason=missing elapsed_ms={}",
started.elapsed().as_millis()
),
Err(e) => log::error!("GeoIP database download failed reason=missing err=\"{e}\""),
}
}
Ok(false) => {}
Err(e) => log::error!("Failed to check GeoIP database status at startup: {e}"),
Err(e) => log::error!("GeoIP database check failed err=\"{e}\""),
}
});
}
@@ -2989,16 +3008,12 @@ pub fn run_with_builder(
.await
{
Ok(dead_pids) => {
PROXY_CLEANUP.succeeded();
if !dead_pids.is_empty() {
log::info!(
"Cleaned up proxies for {} dead browser processes",
dead_pids.len()
);
log::info!("Stopped proxies of exited browsers browser_pids={dead_pids:?}");
}
}
Err(e) => {
log::error!("Error during proxy cleanup: {e}");
}
Err(e) => PROXY_CLEANUP.failed(e),
}
}
});
@@ -3027,7 +3042,7 @@ pub fn run_with_builder(
let profiles = match runner.profile_manager.list_profiles() {
Ok(p) => p,
Err(e) => {
log::warn!("Failed to list profiles in status checker: {e}");
STATUS_CHECK.failed(format!("list profiles: {e}"));
continue;
}
};
@@ -3072,6 +3087,7 @@ pub fn run_with_builder(
})
.collect();
let mut status_error = None;
for profile in profiles_to_check {
let had_pid = profile.process_id.is_some();
// Check browser status and track changes
@@ -3095,10 +3111,7 @@ pub fn run_with_builder(
if should_emit {
log::debug!(
"Status checker detected change for profile {}: {} -> {}",
profile.name,
last_state,
is_running
"Browser running state changed profile={profile_id} running={is_running} was={last_state}"
);
#[derive(serde::Serialize)]
@@ -3113,13 +3126,7 @@ pub fn run_with_builder(
};
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Failed to emit profile running changed event: {e}");
} else {
log::debug!(
"Status checker emitted profile-running-changed event for {}: running={}",
profile.name,
is_running
);
log::warn!("Emit profile-running-changed failed err=\"{e}\"");
}
// Re-encrypt password-protected profiles when the browser
@@ -3172,11 +3179,15 @@ pub fn run_with_builder(
}
}
Err(e) => {
log::warn!("Status check failed for profile {}: {}", profile.name, e);
status_error = Some(format!("profile={} {e}", profile.id));
continue;
}
}
}
match status_error {
Some(e) => STATUS_CHECK.failed(e),
None => STATUS_CHECK.succeeded(),
}
}
});
@@ -3188,12 +3199,11 @@ pub fn run_with_builder(
match crate::settings_manager::get_app_settings(app_handle_api.clone()).await {
Ok(settings) => {
if settings.api_enabled {
log::info!("API is enabled in settings, starting API server...");
match crate::api_server::start_api_server_internal(settings.api_port, &app_handle_api)
.await
{
Ok(port) => {
log::info!("API server started successfully on port {port}");
log::info!("API server started port={port}");
// Emit success toast to frontend
if let Err(e) = events::emit(
"show-toast",
@@ -3204,11 +3214,11 @@ pub fn run_with_builder(
description: Some(format!("API server running on port {port}")),
},
) {
log::error!("Failed to emit API start toast: {e}");
log::error!("Emit API start toast failed err=\"{e}\"");
}
}
Err(e) => {
log::error!("Failed to start API server at startup: {e}");
log::error!("API server start failed port={} err=\"{e}\"", settings.api_port);
// Emit error toast to frontend
if let Err(toast_err) = events::emit(
"show-toast",
@@ -3219,14 +3229,14 @@ pub fn run_with_builder(
description: Some(format!("Error: {e}")),
},
) {
log::error!("Failed to emit API error toast: {toast_err}");
log::error!("Emit API error toast failed err=\"{toast_err}\"");
}
}
}
}
}
Err(e) => {
log::error!("Failed to load app settings for API startup: {e}");
log::error!("Reading settings for API startup failed err=\"{e}\"");
}
}
});
@@ -3249,18 +3259,15 @@ pub fn run_with_builder(
// api_call_with_retry handles 401/refresh internally — no direct
// refresh_access_token call needed.
if cloud_auth::CLOUD_AUTH.is_logged_in().await {
// Both calls log their failures through cloud_auth's streaks.
let sync_token_fut = async {
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
log::warn!("Failed to refresh cloud sync token on startup: {e}");
}
let _ = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await;
};
let proxy_fut = async {
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
};
let wayfern_fut = async {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
}
let _ = cloud_auth::CLOUD_AUTH.request_wayfern_token().await;
};
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
@@ -3569,6 +3576,8 @@ pub fn run_with_builder(
// one account holds one bridge at a time, so an instance that exits
// without hanging up delays the account's next machine.
mcp_remote::stop(None);
ephemeral_dirs::detach_unused_ram_disk();
log::info!("Donut exiting {}", process_report::summary());
tauri::async_runtime::block_on(
team_lock::PROFILE_LOCK.release_all_held_within(team_lock::SHUTDOWN_RELEASE_TIMEOUT),
);
@@ -3591,6 +3600,20 @@ pub fn run_with_builder(
mod tests {
use std::fs;
#[test]
fn log_targets_drop_what_every_line_repeats() {
assert_eq!(
super::short_log_target("donutbrowser_lib::sync::scheduler"),
"sync::scheduler"
);
assert_eq!(super::short_log_target("donutbrowser_lib"), "app");
assert_eq!(
super::short_log_target("webview::http://tauri.localhost/_next/static/chunks/app.js:1:2"),
"webview"
);
assert_eq!(super::short_log_target("donut_proxy"), "donut_proxy");
}
#[test]
fn a_command_line_yields_the_links_and_files_it_carries() {
let exe = "C:/Program Files/Donut Browser/donutbrowser.exe".to_string();
+391
View File
@@ -0,0 +1,391 @@
//! Lines that would otherwise repeat.
//!
//! `Streak`: a failure that repeats, logged as the first failure, a reminder at
//! most once an hour while it lasts, and the recovery with its count. A backend
//! that is unreachable for a day writes about 25 lines, not thousands. A
//! failure within a minute of a recovery continues the old streak as a flap,
//! so a link that connects and drops again writes a few lines an hour, not a
//! pair per cycle.
//! `KeyedStreak`: the same, one streak per entity, so one entity's success
//! never hides another's failure. `Throttle`: an event that is normal one at a
//! time but noisy in bulk (a refused request), written at most once per window
//! per key with the count it dropped.
use std::collections::BTreeMap;
use std::fmt::Display;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const REMINDER_EVERY: Duration = Duration::from_secs(3600);
const SETTLE: Duration = Duration::from_secs(60);
/// Declare as a `static` in the module that fails, so lines keep its target:
/// `static SSE: Streak = Streak::new(module_path!(), "Sync SSE connect");`
pub struct Streak {
target: &'static str,
what: &'static str,
state: Mutex<Option<State>>,
}
/// Like `Streak`, with one streak per key such as `profile=<uuid>`. A key is
/// dropped once it has stayed recovered past the flap window.
pub struct KeyedStreak {
target: &'static str,
what: &'static str,
states: Mutex<BTreeMap<String, State>>,
}
/// `if let Some(dropped) = REFUSED.allow(&key) { log::warn!("...{}", suppressed(dropped)) }`
pub struct Throttle {
window: Duration,
slots: Mutex<BTreeMap<String, (Instant, u64)>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct State {
failures: u64,
since: Instant,
reminded: Instant,
/// Set while the last word was a success.
recovered_at: Option<Instant>,
flaps: u64,
recovery_logged: Instant,
}
impl State {
fn settled(&self, now: Instant) -> bool {
self
.recovered_at
.is_some_and(|at| now.duration_since(at) >= SETTLE)
}
}
#[derive(Debug, PartialEq, Eq)]
enum Event {
Started,
StillFailing {
failures: u64,
for_s: u64,
},
Recovered {
failures: u64,
after_s: u64,
flaps: u64,
},
}
impl Streak {
pub const fn new(target: &'static str, what: &'static str) -> Self {
Streak {
target,
what,
state: Mutex::new(None),
}
}
pub fn failed(&self, err: impl Display) {
let event = match self.state.lock() {
Ok(mut state) => on_failure(&mut state, Instant::now()),
Err(_) => Some(Event::Started),
};
emit(self.target, self.what, "", event, Some(&err));
}
pub fn succeeded(&self) {
let event = match self.state.lock() {
Ok(mut state) => on_success(&mut state, Instant::now()),
Err(_) => None,
};
emit(self.target, self.what, "", event, None);
}
}
impl KeyedStreak {
pub const fn new(target: &'static str, what: &'static str) -> Self {
KeyedStreak {
target,
what,
states: Mutex::new(BTreeMap::new()),
}
}
pub fn failed(&self, key: &str, err: impl Display) {
let event = match self.states.lock() {
Ok(mut states) => {
let now = Instant::now();
if states.len() > 256 {
states.retain(|_, state| !state.settled(now));
}
let mut state = states.get(key).copied();
let event = on_failure(&mut state, now);
if let Some(state) = state {
states.insert(key.to_string(), state);
}
event
}
Err(_) => Some(Event::Started),
};
emit(self.target, self.what, key, event, Some(&err));
}
pub fn succeeded(&self, key: &str) {
let event = match self.states.lock() {
Ok(mut states) => {
let mut state = states.get(key).copied();
let event = on_success(&mut state, Instant::now());
if let Some(state) = state {
states.insert(key.to_string(), state);
}
event
}
Err(_) => None,
};
emit(self.target, self.what, key, event, None);
}
}
impl Throttle {
pub const fn new(window: Duration) -> Self {
Throttle {
window,
slots: Mutex::new(BTreeMap::new()),
}
}
/// `Some(dropped)` when a line for `key` should be written now, with the
/// count skipped since the last one; `None` to skip it.
pub fn allow(&self, key: &str) -> Option<u64> {
let Ok(mut slots) = self.slots.lock() else {
return Some(0);
};
let now = Instant::now();
if slots.len() > 1024 {
let window = self.window;
slots.retain(|_, (last, _)| now.duration_since(*last) < window * 2);
}
let mut slot = slots.get(key).copied();
let allowed = throttle_step(&mut slot, now, self.window);
if let Some(slot) = slot {
slots.insert(key.to_string(), slot);
}
allowed
}
}
/// `" suppressed=N"` for a throttled line that dropped N others, else `""`.
pub fn suppressed(dropped: u64) -> String {
if dropped == 0 {
String::new()
} else {
format!(" suppressed={dropped}")
}
}
fn emit(target: &str, what: &str, key: &str, event: Option<Event>, err: Option<&dyn Display>) {
let key = if key.is_empty() {
String::new()
} else {
format!(" {key}")
};
let err = err.map(|err| format!(" err=\"{err}\"")).unwrap_or_default();
match event {
Some(Event::Started) => log::warn!(target: target, "{what} failed{key}{err}"),
Some(Event::StillFailing { failures, for_s }) => log::warn!(
target: target,
"{what} still failing{key} failures={failures} for_s={for_s}{err}"
),
Some(Event::Recovered {
failures,
after_s,
flaps,
}) => {
let flaps = if flaps == 0 {
String::new()
} else {
format!(" flaps={flaps}")
};
log::info!(
target: target,
"{what} recovered{key} failures={failures} after_s={after_s}{flaps}"
)
}
None => {}
}
}
fn on_failure(state: &mut Option<State>, now: Instant) -> Option<Event> {
let streak = match state {
Some(streak) if !streak.settled(now) => streak,
_ => {
*state = Some(State {
failures: 1,
since: now,
reminded: now,
recovered_at: None,
flaps: 0,
recovery_logged: now,
});
return Some(Event::Started);
}
};
if streak.recovered_at.take().is_some() {
streak.flaps += 1;
}
streak.failures += 1;
if now.duration_since(streak.reminded) < REMINDER_EVERY {
return None;
}
streak.reminded = now;
Some(Event::StillFailing {
failures: streak.failures,
for_s: now.duration_since(streak.since).as_secs(),
})
}
fn on_success(state: &mut Option<State>, now: Instant) -> Option<Event> {
let streak = state.as_mut()?;
if streak.recovered_at.is_some() {
return None;
}
streak.recovered_at = Some(now);
if streak.flaps > 0 && now.duration_since(streak.recovery_logged) < REMINDER_EVERY {
return None;
}
streak.recovery_logged = now;
Some(Event::Recovered {
failures: streak.failures,
after_s: now.duration_since(streak.since).as_secs(),
flaps: streak.flaps,
})
}
fn throttle_step(slot: &mut Option<(Instant, u64)>, now: Instant, window: Duration) -> Option<u64> {
match slot {
None => {
*slot = Some((now, 0));
Some(0)
}
Some((last, dropped)) if now.duration_since(*last) >= window => {
let skipped = *dropped;
*last = now;
*dropped = 0;
Some(skipped)
}
Some((_, dropped)) => {
*dropped += 1;
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_streak_logs_its_start_an_hourly_reminder_and_its_end() {
let start = Instant::now();
let mut state = None;
assert_eq!(on_failure(&mut state, start), Some(Event::Started));
for minute in 1..60 {
let now = start + Duration::from_secs(minute * 60);
assert_eq!(on_failure(&mut state, now), None, "minute {minute}");
}
assert_eq!(
on_failure(&mut state, start + REMINDER_EVERY),
Some(Event::StillFailing {
failures: 61,
for_s: 3600
})
);
assert_eq!(
on_success(&mut state, start + Duration::from_secs(3700)),
Some(Event::Recovered {
failures: 61,
after_s: 3700,
flaps: 0
})
);
assert_eq!(
on_success(&mut state, start + Duration::from_secs(3800)),
None,
"a second success says nothing"
);
}
#[test]
fn success_without_a_streak_says_nothing() {
let mut state = None;
assert_eq!(on_success(&mut state, Instant::now()), None);
}
#[test]
fn a_failure_after_a_settled_recovery_starts_a_new_streak() {
let start = Instant::now();
let mut state = None;
on_failure(&mut state, start);
on_success(&mut state, start);
assert_eq!(on_failure(&mut state, start + SETTLE), Some(Event::Started));
}
#[test]
fn a_link_that_keeps_dropping_flaps_inside_one_streak() {
let start = Instant::now();
let mut state = None;
let mut lines = Vec::new();
// Connects for 5s, drops, reconnects 5s later, for two hours.
for cycle in 0..720u64 {
let failed_at = start + Duration::from_secs(cycle * 10);
lines.extend(on_failure(&mut state, failed_at));
lines.extend(on_success(&mut state, failed_at + Duration::from_secs(5)));
}
assert_eq!(lines[0], Event::Started);
assert!(matches!(lines[1], Event::Recovered { flaps: 0, .. }));
assert!(
lines.len() <= 6,
"two hours of flapping wrote {} lines: {lines:?}",
lines.len()
);
assert!(lines
.iter()
.any(|line| matches!(line, Event::Recovered { flaps, .. } if *flaps > 0)));
}
#[test]
fn one_key_recovering_leaves_another_key_failing() {
let streak = KeyedStreak::new(module_path!(), "Report");
streak.failed("profile=a", "down");
streak.failed("profile=b", "down");
streak.succeeded("profile=a");
let states = streak.states.lock().unwrap();
assert!(states["profile=a"].recovered_at.is_some());
assert!(states["profile=b"].recovered_at.is_none());
assert_eq!(states["profile=b"].failures, 1);
}
#[test]
fn a_throttled_key_writes_once_per_window_with_what_it_dropped() {
let start = Instant::now();
let window = Duration::from_secs(60);
let mut slot = None;
assert_eq!(throttle_step(&mut slot, start, window), Some(0));
for second in 1..60 {
let now = start + Duration::from_secs(second);
assert_eq!(throttle_step(&mut slot, now, window), None);
}
assert_eq!(throttle_step(&mut slot, start + window, window), Some(59));
assert_eq!(suppressed(59), " suppressed=59");
assert_eq!(suppressed(0), "");
}
#[test]
fn throttle_keys_are_independent() {
let throttle = Throttle::new(Duration::from_secs(60));
assert_eq!(throttle.allow("GET /a 401"), Some(0));
assert_eq!(throttle.allow("GET /a 401"), None);
assert_eq!(throttle.allow("GET /b 401"), Some(0));
}
}
+25 -20
View File
@@ -117,6 +117,9 @@ const WRITER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
static BRIDGE_RUNNING: AtomicBool = AtomicBool::new(false);
static BRIDGE_TASK: Mutex<Option<tauri::async_runtime::JoinHandle<()>>> = Mutex::new(None);
static BRIDGE_CONNECTED: AtomicBool = AtomicBool::new(false);
static BRIDGE_ANNOUNCED: AtomicBool = AtomicBool::new(false);
static BRIDGE_LINK: crate::log_streak::Streak =
crate::log_streak::Streak::new(module_path!(), "Remote control bridge");
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
/// What the Integrations page shows about remote control.
@@ -252,9 +255,7 @@ fn read_or_create_instance_id(path: &std::path::Path) -> String {
return trimmed.to_string();
}
if !trimmed.is_empty() {
log::warn!(
"[mcp-remote] Persisted instance id is not a shape the bridge accepts; regenerating"
);
log::warn!("Remote control instance id malformed, regenerated");
}
}
@@ -265,7 +266,7 @@ fn read_or_create_instance_id(path: &std::path::Path) -> String {
if let Err(e) = std::fs::write(path, &fresh) {
// A machine that cannot persist this still works; it just loses the
// reclaim-my-own-slot property until the write succeeds.
log::warn!("[mcp-remote] Could not persist the instance id: {e}");
log::warn!("Remote control instance id not saved err=\"{e}\"");
}
fresh
}
@@ -384,6 +385,7 @@ async fn run() {
// of exactly once.
let mut refreshed = false;
let greeted = Arc::new(AtomicBool::new(false));
BRIDGE_ANNOUNCED.store(false, Ordering::SeqCst);
while BRIDGE_RUNNING.load(Ordering::SeqCst) {
// `dialled_with` is the access token the socket was dialled with, so a
@@ -392,7 +394,7 @@ async fn run() {
let (failure, dialled_with) = match connect().await {
Ok((stream, token)) => {
greeted.store(false, Ordering::SeqCst);
log::info!("[mcp-remote] Bridge dialled as instance {}", instance_id());
log::debug!("Remote control bridge dialled instance={}", instance_id());
// The connection is announced by `dispatch`, synchronously, the moment
// the `hello` is read, NOT here, and no longer from a task of its own.
@@ -439,8 +441,8 @@ async fn run() {
}
}
match &outcome {
Ok(()) => log::info!("[mcp-remote] Bridge closed by the backend"),
Err(e) => log::warn!("[mcp-remote] Bridge ended: {e}"),
Ok(()) => log::debug!("Remote control bridge closed by the backend"),
Err(e) => BRIDGE_LINK.failed(e),
}
// The code for the screen, the prose for the log above.
publish_state(
@@ -453,7 +455,7 @@ async fn run() {
(outcome.err(), Some(token))
}
Err(e) => {
log::warn!("[mcp-remote] Bridge could not connect: {e}");
BRIDGE_LINK.failed(&e);
publish_state(false, Some(crate::backend_error(e.code())));
(Some(e), None)
}
@@ -487,14 +489,12 @@ async fn run() {
.as_deref(),
);
if rotated {
log::info!(
"[mcp-remote] The refused access token has already been replaced; redialling with the current one"
);
log::debug!("Remote control bridge redialling with the already rotated token");
attempt = 0;
} else if should_refresh_credential(error, refreshed) {
match CLOUD_AUTH.refresh_access_token().await {
Ok(()) => {
log::info!("[mcp-remote] Refreshed the access token; retrying the bridge at once");
log::debug!("Remote control bridge retrying with a refreshed token");
// Spent only NOW. Marking it before the attempt meant a refresh
// that FAILED, a transient network blip at exactly the wrong
// moment, permanently disarmed the retry: the reset needs a
@@ -505,7 +505,7 @@ async fn run() {
attempt = 0;
}
Err(e) => {
log::warn!("[mcp-remote] Could not refresh the access token: {e}");
log::warn!("Remote control bridge token refresh failed err=\"{e}\"");
attempt = attempt.max(TERMINAL_BACKOFF_ATTEMPT);
}
}
@@ -527,7 +527,7 @@ async fn run() {
}
BRIDGE_CONNECTED.store(false, Ordering::SeqCst);
log::info!("[mcp-remote] Bridge stopped");
log::info!("Remote control bridge stopped");
}
/// Whether this failure is worth one token refresh and an immediate retry.
@@ -804,7 +804,7 @@ async fn drain_writer(writer: tokio::task::JoinHandle<()>, budget: Duration) {
let mut writer = writer;
if tokio::time::timeout(budget, &mut writer).await.is_err() {
log::warn!(
"[mcp-remote] A relayed call outlived its socket; abandoning the writer after {}s",
"Remote control relayed call outlived its socket, writer abandoned after_s={}",
budget.as_secs()
);
writer.abort();
@@ -867,7 +867,7 @@ fn parse_frame(text: &str) -> Option<BridgeFrame> {
other => {
// Forward compatibility: a newer relay may add frames this build has
// never heard of, and dropping one must not take the socket down.
log::debug!("[mcp-remote] Ignoring unknown bridge frame '{other}'");
log::debug!("Remote control bridge frame unknown, ignored frame={other}");
None
}
}
@@ -885,11 +885,16 @@ fn dispatch(
// is what makes it the signal `run` uses to allow another refresh and to
// tell the screen the bridge is actually up.
greeted.store(true, Ordering::SeqCst);
log::info!("[mcp-remote] Bridge accepted; the relay greeted us");
BRIDGE_LINK.succeeded();
if BRIDGE_ANNOUNCED.swap(true, Ordering::SeqCst) {
log::debug!("Remote control bridge connected instance={}", instance_id());
} else {
log::info!("Remote control bridge connected instance={}", instance_id());
}
publish_state(true, None);
if protocol != BRIDGE_PROTOCOL {
log::warn!(
"[mcp-remote] The bridge speaks '{protocol}' and this build speaks '{BRIDGE_PROTOCOL}'"
"Remote control bridge protocol mismatch relay={protocol} build={BRIDGE_PROTOCOL}"
);
}
}
@@ -904,7 +909,7 @@ fn dispatch(
payload,
} => {
let Ok(permit) = Arc::clone(permits).try_acquire_owned() else {
log::warn!("[mcp-remote] Refused a relayed call: {MAX_IN_FLIGHT} already in flight");
log::warn!("Remote control relayed call refused: busy in_flight={MAX_IN_FLIGHT}");
let _ = tx.try_send(encode(&serde_json::json!({
"t": "result",
"cid": cid,
@@ -938,7 +943,7 @@ fn result_frame(cid: &str, outcome: McpOutcome) -> serde_json::Value {
let encoded = body.to_string();
if encoded.len() > MAX_RESULT_BYTES {
log::warn!(
"[mcp-remote] Result of {} bytes exceeds the {MAX_RESULT_BYTES}-byte frame budget",
"Remote control result too large bytes={} max={MAX_RESULT_BYTES}",
encoded.len()
);
return serde_json::json!({
+28 -25
View File
@@ -21,6 +21,9 @@ use crate::wayfern_cdp::{
PerceptionStats, PickedElement, ResolveOptions, ViewportTarget, WayfernError, WayfernSession,
};
static AUTOMATION_QUOTA: crate::log_streak::Throttle =
crate::log_streak::Throttle::new(std::time::Duration::from_secs(60));
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpTool {
@@ -2107,7 +2110,7 @@ impl McpServer {
),
None => "logged_in=false".to_string(),
};
log::warn!("[mcp] Rejected '{feature}' — plan does not include it ({summary})");
log::warn!("MCP tool refused: plan lacks feature feature=\"{feature}\" {summary}");
return Err(McpError {
code: -32000,
message: format!("{feature} is not included in this account's plan"),
@@ -2138,7 +2141,7 @@ impl McpServer {
let mut inner = self.inner.lock().await;
match inner.sessions.remove(session_id) {
Some(session) => {
log::info!("[mcp] Session terminated: {}", ShortId(session_id));
log::info!("MCP session ended session={}", ShortId(session_id));
crate::agent_console::session_ended(session_id);
session.cached_pages
}
@@ -2161,8 +2164,7 @@ impl McpServer {
.is_err()
{
log::debug!(
"[mcp] Session {} ended before its element caches could be cleared; the \
page-side slot cap will reclaim them",
"MCP session element caches not cleared in time session={}",
ShortId(session_id)
);
}
@@ -2181,7 +2183,7 @@ impl McpServer {
Ok(target) => target,
Err(e) => {
log::debug!(
"[mcp] Skipped clearing an element cache on profile {profile_id}: {}",
"MCP element cache clear skipped profile={profile_id} err=\"{}\"",
e.message
);
continue;
@@ -2209,7 +2211,7 @@ impl McpServer {
.await
{
log::debug!(
"[mcp] Could not clear an element cache on profile {profile_id}: {}",
"MCP element cache clear failed profile={profile_id} err=\"{}\"",
e.message
);
}
@@ -2308,9 +2310,12 @@ impl McpServer {
if let crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } =
crate::automation_rate_limiter::check_automation_rate_limit().await
{
log::warn!(
"[mcp] Rejected tools/call: automation rate limit exceeded; retry in {retry_after_secs}s"
);
if let Some(dropped) = AUTOMATION_QUOTA.allow("limited") {
log::warn!(
"MCP automation rate limited retry_after_s={retry_after_secs}{}",
crate::log_streak::suppressed(dropped)
);
}
return McpOutcome::RateLimited { retry_after_secs };
}
}
@@ -4231,7 +4236,10 @@ impl McpServer {
else {
break;
};
log::warn!("[mcp] Session cap reached; evicting the oldest session");
log::warn!(
"MCP session cap reached, oldest evicted session={}",
ShortId(&oldest)
);
inner.sessions.remove(&oldest);
evicted.push(oldest);
}
@@ -4259,7 +4267,7 @@ impl McpServer {
"instructions": instructions
});
log::info!("[mcp] New session initialized: {}", ShortId(&session_id));
log::info!("MCP session started session={}", ShortId(&session_id));
Ok((session_id, (id, result)))
}
@@ -4337,24 +4345,19 @@ impl McpServer {
.cloned()
.unwrap_or(serde_json::json!({}));
// Surface the call in logs so customer reports show which tools the MCP
// client is actually invoking (and therefore which gate any subsequent
// error came from). Log only the tool name and the profile_id arg —
// arbitrary URLs / JS / selectors can be sensitive.
// Log only the tool name and the profile_id arg: arbitrary URLs / JS /
// selectors can be sensitive.
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[mcp] tools/call name={tool_name} profile_id={profile_id}");
let started = std::time::Instant::now();
// Refused here, at the one place every tool call passes, rather than in
// each handler: a new path-taking tool added later inherits the rule
// instead of having to remember it.
if LOCAL_PATH_TOOLS.contains(&tool_name) || SECRET_EXPORT_TOOLS.contains(&tool_name) {
log::warn!(
"[mcp] Refused '{tool_name}': it takes a local filesystem path or exports stored secrets"
);
log::warn!("MCP tool refused: local paths or secrets tool={tool_name}");
return Err(McpError {
code: -32000,
message: crate::backend_error("TOOL_IS_LOCAL_ONLY"),
@@ -4402,13 +4405,13 @@ impl McpServer {
}
match &result {
Ok(_) => {
log::info!(
"[mcp] tools/call name={tool_name} profile_id={profile_id} -> ok ({elapsed_ms} ms)"
log::debug!(
"MCP tool call ok tool={tool_name} profile={profile_id} elapsed_ms={elapsed_ms}"
);
}
Err(e) => {
log::warn!(
"[mcp] tools/call name={tool_name} profile_id={profile_id} -> error code={} msg={:?} ({elapsed_ms} ms)",
"MCP tool call failed tool={tool_name} profile={profile_id} code={} elapsed_ms={elapsed_ms} err={:?}",
e.code,
e.message
);
@@ -5283,7 +5286,7 @@ impl McpServer {
if message.contains("PROFILE_GENERATION_LIMIT_REACHED") {
let _ = crate::events::emit_empty("profile-generation-limit-reached");
}
log::warn!("[mcp] Could not create profile '{name}': {message}");
log::warn!("MCP profile create failed err=\"{message}\"");
McpError {
code: -32000,
message: format!("Failed to create profile: {message}"),
@@ -7567,7 +7570,7 @@ impl McpServer {
data: None,
})?;
if let Err(e) = crate::events::emit_empty("extensions-changed") {
log::error!("Failed to emit extensions-changed event: {e}");
log::warn!("Event not emitted event=extensions-changed err=\"{e}\"");
}
Ok(serde_json::json!({"success": true}))
}
@@ -9012,7 +9015,7 @@ impl McpServer {
.map(|_| ())
.map_err(|e| {
log::warn!(
"[mcp] Could not bring profile {profile_id} to front: {}",
"MCP profile not brought to front profile={profile_id} err=\"{}\"",
e.message
);
crate::backend_error("PROFILE_NOT_RUNNING")
+38 -51
View File
@@ -98,7 +98,11 @@ pub mod macos {
executable_path: &std::path::Path,
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
log::info!("Launching browser on macOS: {executable_path:?} with args: {args:?}");
log::debug!(
"Spawning browser path={} args={}",
executable_path.display(),
args.len()
);
// If the executable is inside an app bundle, launch via Launch Services so
// macOS recognizes the real application for privacy permissions (e.g. Screen Recording).
// This ensures TCC prompts are attributed to the browser app, not our launcher.
@@ -137,8 +141,6 @@ pub mod macos {
pid: u32,
profile_data_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
log::info!("Attempting to kill browser process with PID: {pid}");
let mut pids_to_kill = vec![pid];
let descendants = get_all_descendant_pids(pid).await;
@@ -148,16 +150,15 @@ pub mod macos {
let additional_pids = find_processes_by_profile_path(profile_path).await;
for p in additional_pids {
if !pids_to_kill.contains(&p) {
log::info!("Found additional process {} using profile path", p);
log::debug!("Found another process on the profile path pid={p}");
pids_to_kill.push(p);
}
}
}
log::info!("Total processes to kill: {:?}", pids_to_kill);
log::debug!("Force killing browser processes pid={pid} pids={pids_to_kill:?}");
for &p in &pids_to_kill {
log::info!("Sending SIGKILL to PID: {p}");
let _ = Command::new("kill")
.args(["-KILL", &p.to_string()])
.output();
@@ -176,7 +177,7 @@ pub mod macos {
for &p in &pids_to_kill {
let system = System::new_all();
if system.process(Pid::from(p as usize)).is_some() {
log::info!("Process {p} still running, retrying kill");
log::debug!("Process still running, killing again pid={p}");
let _ = Command::new("kill")
.args(["-KILL", &p.to_string()])
.output();
@@ -194,10 +195,7 @@ pub mod macos {
}
if !still_running.is_empty() {
log::info!(
"Processes {:?} still running, trying final termination",
still_running
);
log::debug!("Processes still running, final kill pids={still_running:?}");
for p in &still_running {
let _ = Command::new("/bin/kill")
@@ -216,10 +214,6 @@ pub mod macos {
}
if !final_still_running.is_empty() {
log::error!(
"ERROR: Processes {:?} could not be terminated despite aggressive attempts",
final_still_running
);
return Err(
format!(
"Failed to terminate browser processes {:?} - still running",
@@ -230,7 +224,10 @@ pub mod macos {
}
}
log::info!("Browser termination completed for PID: {pid}");
log::debug!(
"Browser processes killed pid={pid} count={}",
pids_to_kill.len()
);
Ok(())
}
@@ -298,7 +295,6 @@ pub mod macos {
let pid = profile.process_id.unwrap();
// First, try using the browser's built-in URL opening capability
log::info!("Trying Chromium URL opening for PID: {pid}");
let browser = create_browser(browser_type);
if let Ok(executable_path) = browser.get_executable_path(browser_dir) {
@@ -312,15 +308,16 @@ pub mod macos {
match remote_output {
Ok(output) if output.status.success() => {
log::info!("Chromium URL opening succeeded");
return Ok(());
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::info!("Chromium URL opening failed: {stderr}, trying AppleScript");
log::debug!(
"Chromium remote URL open failed pid={pid} stderr={:?}",
crate::log_redaction::text(&String::from_utf8_lossy(&output.stderr))
);
}
Err(e) => {
log::info!("Chromium URL opening error: {e}, trying AppleScript");
log::debug!("Chromium remote URL open did not run pid={pid} err=\"{e}\"");
}
}
}
@@ -349,10 +346,10 @@ pub mod windows {
executable_path: &std::path::Path,
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
log::info!(
"Launching browser on Windows: {:?} with args: {:?}",
executable_path,
args
log::debug!(
"Spawning browser path={} args={}",
executable_path.display(),
args.len()
);
// Check if the executable exists
@@ -391,10 +388,7 @@ pub mod windows {
.spawn()
.map_err(|e| format!("Failed to launch browser process: {}", e))?;
log::info!(
"Successfully launched browser process with PID: {}",
child.id()
);
log::debug!("Browser spawned pid={}", child.id());
Ok(child)
}
@@ -445,7 +439,7 @@ pub mod windows {
let system = System::new_all();
if let Some(process) = system.process(Pid::from(pid as usize)) {
if process.kill() {
log::info!("Successfully killed browser process with PID: {pid}");
log::debug!("Browser process killed pid={pid}");
return Ok(());
}
}
@@ -464,7 +458,7 @@ pub mod windows {
match output {
Ok(result) => {
if result.status.success() {
log::info!("Successfully killed browser process with PID: {pid} using taskkill");
log::debug!("Browser process killed with taskkill pid={pid}");
Ok(())
} else {
Err(
@@ -491,10 +485,10 @@ pub mod linux {
executable_path: &std::path::Path,
args: &[String],
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
log::info!(
"Launching browser on Linux: {:?} with args: {:?}",
executable_path,
args
log::debug!(
"Spawning browser path={} args={}",
executable_path.display(),
args.len()
);
// Check if the executable exists and is executable
@@ -557,7 +551,7 @@ pub mod linux {
// Set the combined LD_LIBRARY_PATH
if !ld_library_path.is_empty() {
cmd.env("LD_LIBRARY_PATH", ld_library_path.join(":"));
log::info!("Set LD_LIBRARY_PATH to: {}", ld_library_path.join(":"));
log::debug!("LD_LIBRARY_PATH={}", ld_library_path.join(":"));
}
}
@@ -580,7 +574,7 @@ pub mod linux {
// available. Using OR here would fire on every normal Wayland-only session
// (DISPLAY unset) or X11-only session (WAYLAND_DISPLAY unset).
if std::env::var("DISPLAY").is_err() && std::env::var("WAYLAND_DISPLAY").is_err() {
log::info!("No display detected, browser may fail to start");
log::warn!("No X11 or Wayland display, browser may fail to start");
}
// Attempt to spawn with better error handling for architecture issues
@@ -652,8 +646,6 @@ pub mod linux {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
log::info!("Attempting to kill browser process with PID: {pid}");
let mut pids_to_kill = vec![pid];
// Find all descendant processes
@@ -665,17 +657,16 @@ pub mod linux {
let additional_pids = find_processes_by_profile_path(profile_path);
for p in additional_pids {
if !pids_to_kill.contains(&p) {
log::info!("Found additional process {} using profile path", p);
log::debug!("Found another process on the profile path pid={p}");
pids_to_kill.push(p);
}
}
}
log::info!("Total processes to kill: {:?}", pids_to_kill);
log::debug!("Force killing browser processes pid={pid} pids={pids_to_kill:?}");
// Send SIGKILL to all identified processes
for &p in &pids_to_kill {
log::info!("Sending SIGKILL to PID: {p}");
let _ = Command::new("kill")
.args(["-KILL", &p.to_string()])
.output();
@@ -701,10 +692,7 @@ pub mod linux {
}
if !still_running.is_empty() {
log::info!(
"Processes {:?} still running, trying final termination",
still_running
);
log::debug!("Processes still running, final kill pids={still_running:?}");
for p in &still_running {
let _ = Command::new("kill")
@@ -725,10 +713,6 @@ pub mod linux {
}
if !final_still_running.is_empty() {
log::error!(
"ERROR: Processes {:?} could not be terminated despite aggressive attempts",
final_still_running
);
return Err(
format!(
"Failed to terminate browser processes {:?} - still running",
@@ -739,7 +723,10 @@ pub mod linux {
}
}
log::info!("Browser termination completed for PID: {pid}");
log::debug!(
"Browser processes killed pid={pid} count={}",
pids_to_kill.len()
);
Ok(())
}
+63
View File
@@ -0,0 +1,63 @@
//! What Donut has running outside its own process, as `key=[id@pid ...]`
//! fields for one log line. Logged at startup (what an earlier run left) and at
//! exit (what outlives this run), so a report that Donut "keeps running" can be
//! answered from the log alone.
use crate::proxy_storage::is_process_running;
pub fn summary() -> String {
let browsers = crate::profile::ProfileManager::instance()
.list_profiles()
.unwrap_or_default()
.into_iter()
.filter_map(|profile| {
let pid = profile.process_id.filter(|pid| is_process_running(*pid))?;
Some((profile.id.to_string(), pid))
});
let proxies = crate::proxy_storage::list_proxy_configs()
.into_iter()
.filter_map(|config| Some((config.id, config.pid?)));
let vpns = crate::vpn_worker_storage::list_vpn_worker_configs()
.into_iter()
.filter_map(|config| Some((config.id, config.pid?)));
let xrays = crate::xray_worker_storage::list_xray_worker_configs()
.into_iter()
.filter_map(|config| Some((config.id, config.pid?)));
let mut line = format!(
"browsers={} proxy_workers={} vpn_workers={} xray_workers={}",
alive(browsers),
alive(proxies),
alive(vpns),
alive(xrays)
);
if let Some(mounted) = crate::ephemeral_dirs::ram_disk_mounted() {
line.push_str(if mounted {
" ram_disk=mounted"
} else {
" ram_disk=absent"
});
}
line
}
fn alive(entries: impl Iterator<Item = (String, u32)>) -> String {
let listed: Vec<String> = entries
.filter(|(_, pid)| is_process_running(*pid))
.map(|(id, pid)| format!("{id}@{pid}"))
.collect();
format!("[{}]", listed.join(" "))
}
#[cfg(test)]
mod tests {
use super::alive;
#[test]
fn only_live_processes_are_listed() {
let own = std::process::id();
let listed = alive([("live".to_string(), own), ("gone".to_string(), u32::MAX)].into_iter());
assert_eq!(listed, format!("[live@{own}]"));
assert_eq!(alive(std::iter::empty()), "[]");
}
}
+10 -3
View File
@@ -59,7 +59,10 @@ fn remove_entry(path: &Path) {
fs::remove_file(path)
};
if let Err(e) = result {
log::warn!("clear-on-close: failed to remove {}: {e}", path.display());
log::warn!(
"Clear-on-close removal failed path=\"{}\" err=\"{e}\"",
path.display()
);
}
}
@@ -131,13 +134,17 @@ pub async fn clear_profile_browsing_data(profile: &BrowserProfile) {
let profiles_dir = crate::profile::ProfileManager::instance().get_profiles_dir();
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
let name = profile.name.clone();
let started = std::time::Instant::now();
let cleared = tokio::task::spawn_blocking(move || clear_user_data_dir(&user_data_dir))
.await
.unwrap_or(0);
log::info!("clear-on-close: cleared {cleared} browsing-data entries for profile '{name}'");
log::info!(
"Browsing data cleared on close profile={} entries={cleared} elapsed_ms={}",
profile.id,
started.elapsed().as_millis()
);
}
#[cfg(test)]
+146 -113
View File
@@ -40,6 +40,30 @@ fn holds_profile(content: &str, expected: &BrowserProfile) -> Result<bool, serde
Ok(serde_json::to_value(&stored)? == serde_json::to_value(expected)?)
}
/// `list_profiles` runs every few seconds, so a broken profile warns once per run.
fn warn_skipped_profile(dir: &Path, why: &str, err: &dyn std::fmt::Display) {
static WARNED: std::sync::Mutex<Option<std::collections::HashSet<PathBuf>>> =
std::sync::Mutex::new(None);
let first = WARNED
.lock()
.map(|mut warned| {
warned
.get_or_insert_with(Default::default)
.insert(dir.to_path_buf())
})
.unwrap_or(true);
let profile = dir.file_name().unwrap_or_default().to_string_lossy();
let level = if first {
log::Level::Warn
} else {
log::Level::Debug
};
log::log!(
level,
"Profile skipped: {why} metadata.json profile={profile} err=\"{err}\""
);
}
/// Collapse an empty proxy/VPN id to `None`.
///
/// REST and MCP clients send `""` to detach a proxy or VPN, since omitting the
@@ -192,13 +216,11 @@ impl ProfileManager {
// Sync cloud proxy credentials if the profile uses a cloud or cloud-derived proxy
if let Some(ref pid) = proxy_id {
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
log::info!("Syncing cloud proxy credentials before profile creation");
log::debug!("Syncing cloud proxy credentials before profile creation proxy={pid}");
CLOUD_AUTH.sync_cloud_proxy().await;
}
}
log::info!("Attempting to create profile: {name}");
if browser == "camoufox" {
return Err(
serde_json::json!({ "code": "CAMOUFOX_REMOVED" })
@@ -222,6 +244,12 @@ impl ProfileManager {
// Generate a new UUID for this profile
let profile_id = uuid::Uuid::new_v4();
let started = std::time::Instant::now();
let mut fingerprint_source = if browser == "wayfern" {
"provided"
} else {
"none"
};
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile_id.to_string());
let profile_data_dir = profile_uuid_dir.join("profile");
@@ -235,10 +263,7 @@ impl ProfileManager {
// For Wayfern profiles, generate fingerprint during creation
let final_wayfern_config = if browser == "wayfern" {
let mut config = wayfern_config.unwrap_or_else(|| {
log::info!("Creating default Wayfern config for profile: {name}");
crate::wayfern_manager::WayfernConfig::default()
});
let mut config = wayfern_config.unwrap_or_default();
// Always ensure executable_path is set to the user's binary location
// Pass upstream proxy information to config for fingerprint generation
@@ -264,12 +289,6 @@ impl ProfileManager {
)
};
config.proxy = Some(proxy_url);
log::info!(
"Using upstream proxy for Wayfern fingerprint generation: {}://{}:{}",
proxy_settings.proxy_type.to_lowercase(),
proxy_settings.host,
proxy_settings.port
);
}
}
@@ -298,7 +317,7 @@ impl ProfileManager {
{
let _ = std::fs::remove_dir_all(&profile_uuid_dir);
log::warn!(
"Refused to generate a fingerprint for '{name}': this account has generated its hourly maximum of {per_hour}; retry in {retry_after_secs}s"
"Profile creation refused: fingerprint generation limit reached per_hour={per_hour} retry_after_s={retry_after_secs}"
);
return Err(
serde_json::json!({
@@ -313,11 +332,10 @@ impl ProfileManager {
);
}
log::info!("Generating fingerprint for Wayfern profile: {name}");
// Create a temporary profile for fingerprint generation
// A stand-in for fingerprint generation. It carries the new profile's
// id so the generation's log lines name the profile being created.
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
id: profile_id,
name: name.to_string(),
browser: browser.to_string(),
version: version.to_string(),
@@ -372,7 +390,7 @@ impl ProfileManager {
Some(generated.fingerprint)
};
geolocation_applied = generated.geolocation_applied;
log::info!("Successfully generated fingerprint for Wayfern profile: {name}");
fingerprint_source = "generated";
}
Err(e) => {
// Coded, not wrapped: re-`format!`ing the error here left a string
@@ -384,12 +402,13 @@ impl ProfileManager {
config.os.as_deref(),
);
let _ = std::fs::remove_dir_all(&profile_uuid_dir);
log::error!("Could not create Wayfern profile '{name}': {coded}");
log::error!(
"Profile creation failed step=fingerprint profile={profile_id} elapsed_ms={} err=\"{coded}\"",
started.elapsed().as_millis()
);
return Err(coded.into());
}
}
} else {
log::info!("Using provided fingerprint for Wayfern profile: {name}");
}
if let Some(object) = supplied_device {
@@ -424,7 +443,7 @@ impl ProfileManager {
} else {
if !matches!(config.geoip.as_ref(), Some(serde_json::Value::Bool(false))) {
log::warn!(
"Geolocation could not be applied for Wayfern profile {name}; leaving geo signature unset so the next launch refreshes location through the profile's proxy"
"Geolocation not applied to new fingerprint; next launch refreshes it profile={profile_id}"
);
}
None
@@ -486,11 +505,19 @@ impl ProfileManager {
return Err(format!("Failed to create profile file for '{name}'").into());
}
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
log::info!(
"Profile created profile={profile_id} browser={} version={} proxy={} vpn={} group={} ephemeral={ephemeral} fingerprint={fingerprint_source} elapsed_ms={}",
crate::log_redaction::Plain(browser),
crate::log_redaction::Plain(version),
proxy_id.as_deref().unwrap_or("-"),
vpn_id.as_deref().unwrap_or("-"),
group_id.as_deref().unwrap_or("-"),
started.elapsed().as_millis()
);
// Emit profile creation event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -556,20 +583,14 @@ impl ProfileManager {
let content = match fs::read_to_string(&metadata_file) {
Ok(c) => c,
Err(e) => {
log::warn!(
"Skipping profile at {}: failed to read metadata.json: {e}",
path.display()
);
warn_skipped_profile(&path, "unreadable", &e);
continue;
}
};
let mut profile: BrowserProfile = match serde_json::from_str(&content) {
Ok(p) => p,
Err(e) => {
log::warn!(
"Skipping profile at {}: invalid metadata.json: {e}",
path.display()
);
warn_skipped_profile(&path, "invalid", &e);
continue;
}
};
@@ -660,7 +681,7 @@ impl ProfileManager {
// Emit profile rename event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -729,9 +750,9 @@ impl ProfileManager {
match self.delete_profile_permanently(app_handle, &profile_id) {
Ok(()) => {
swept += 1;
log::info!("Swept temporary profile {profile_id} left by an earlier run");
log::info!("Swept temporary profile left by an earlier run profile={profile_id}");
}
Err(e) => log::warn!("Could not sweep temporary profile {profile_id}: {e}"),
Err(e) => log::warn!("Temporary profile sweep failed profile={profile_id} err=\"{e}\""),
}
}
swept
@@ -766,7 +787,24 @@ impl ProfileManager {
permanent: bool,
emit_events: bool,
) -> Result<(), Box<dyn std::error::Error>> {
log::info!("Attempting to delete profile with ID: {profile_id} (permanent: {permanent})");
let started = std::time::Instant::now();
let outcome = self.remove_profile_unlogged(app_handle, profile_id, permanent, emit_events)?;
log::info!(
"Profile deleted profile={profile_id} {outcome} elapsed_ms={}",
started.elapsed().as_millis()
);
Ok(())
}
/// Returns `key=value` facts about what the removal did, for the log line.
/// Failures log here: some returned errors carry the profile name.
fn remove_profile_unlogged(
&self,
app_handle: &tauri::AppHandle,
profile_id: &str,
permanent: bool,
emit_events: bool,
) -> Result<String, Box<dyn std::error::Error>> {
let profile = self.find_profile(profile_id)?;
if crate::profile::trash::is_running_locally(&profile) {
@@ -796,10 +834,12 @@ impl ProfileManager {
if permanent {
self.forget_profile_side_state(profile_id);
if profile_uuid_dir.exists() {
log::info!("Erasing profile directory: {}", profile_uuid_dir.display());
crate::fs_secure::secure_remove_dir_all(&profile_uuid_dir, true)?;
crate::fs_secure::secure_remove_dir_all(&profile_uuid_dir, true).inspect_err(|e| {
log::error!("Profile erase failed profile={profile_id} err=\"{e}\"");
})?;
}
if profile_uuid_dir.exists() {
log::error!("Profile erase incomplete: directory still exists profile={profile_id}");
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
}
} else {
@@ -810,18 +850,15 @@ impl ProfileManager {
&profile,
now,
retention_days,
)?;
)
.inspect_err(|e| log::error!("Profile trash failed profile={profile_id} err=\"{e}\""))?;
}
log::info!(
"Profile '{}' (ID: {}) {} successfully",
profile.name,
profile_id,
if permanent {
"deleted"
} else {
"moved to trash"
}
let outcome = format!(
"mode={} ephemeral={} sync_delete={}",
if permanent { "erased" } else { "trashed" },
profile.ephemeral,
if was_sync_enabled { "queued" } else { "none" }
);
// The browser is not running, so the team lock is normally released
@@ -843,17 +880,17 @@ impl ProfileManager {
Ok(engine) => {
if let Err(e) = engine.delete_profile(&profile_id_owned).await {
log::warn!(
"Failed to delete profile {} from sync, retrying when sync next starts: {}",
profile_id_owned,
e
"Remote profile delete failed; retried when sync next starts profile={profile_id_owned} err=\"{e}\""
);
} else {
crate::sync::pending_deletes::forget(&profile_id_owned);
log::info!("Profile {} deleted from S3 sync storage", profile_id_owned);
log::info!("Remote profile deleted profile={profile_id_owned}");
}
}
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
log::debug!(
"Remote profile delete skipped: sync not configured profile={profile_id_owned} err=\"{e}\""
);
}
}
});
@@ -864,7 +901,7 @@ impl ProfileManager {
self.after_profiles_removed(!permanent);
}
Ok(())
Ok(outcome)
}
/// State that lives outside the profile directory and only makes sense
@@ -884,15 +921,15 @@ impl ProfileManager {
});
if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() {
log::warn!("Warning: Failed to cleanup unused binaries after profile deletion: {e}");
log::warn!("Unused binary cleanup after profile delete failed err=\"{e}\"");
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
if trashed {
if let Err(e) = events::emit_empty("trash-changed") {
log::warn!("Warning: Failed to emit trash-changed event: {e}");
log::warn!("Event emit failed event=trash-changed err=\"{e}\"");
}
}
}
@@ -930,16 +967,12 @@ impl ProfileManager {
// The normal save path, so tag suggestions pick the profile up again.
self.save_profile(&profile)?;
log::info!(
"Profile '{}' (ID: {}) restored from trash",
profile.name,
profile_id
);
log::info!("Profile restored from trash profile={profile_id}");
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
if let Err(e) = events::emit_empty("trash-changed") {
log::warn!("Warning: Failed to emit trash-changed event: {e}");
log::warn!("Event emit failed event=trash-changed err=\"{e}\"");
}
Ok((profile, sync_mode))
}
@@ -980,10 +1013,10 @@ impl ProfileManager {
self.forget_profile_side_state(id);
}
if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() {
log::warn!("Warning: Failed to cleanup unused binaries after purging the trash: {e}");
log::warn!("Unused binary cleanup after trash purge failed err=\"{e}\"");
}
if let Err(e) = events::emit_empty("trash-changed") {
log::warn!("Warning: Failed to emit trash-changed event: {e}");
log::warn!("Event emit failed event=trash-changed err=\"{e}\"");
}
}
@@ -1003,13 +1036,13 @@ impl ProfileManager {
let profile_dir = profiles_dir.join(profile_id);
if profile_dir.exists() {
crate::fs_secure::secure_remove_dir_all(&profile_dir, true)?;
log::info!("Erased local profile {} (tombstoned remotely)", profile_id);
log::info!("Profile erased: deleted on another device profile={profile_id}");
}
if let Err(e) = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance()
.cleanup_unused_binaries()
{
log::warn!("Failed to cleanup binaries after tombstone deletion: {e}");
log::warn!("Unused binary cleanup after remote delete failed err=\"{e}\"");
}
let _ = crate::events::emit_empty("profiles-changed");
@@ -1071,10 +1104,9 @@ impl ProfileManager {
cfg.fingerprint = None;
cfg.geo_proxy_signature = None;
log::warn!(
"Profile '{}' moved from Wayfern {} to {}. Its stored fingerprint cannot be used there, so it was cleared and a fresh one will be generated on the next launch.",
profile.name,
profile.version,
version
"Fingerprint cleared: incompatible with new Wayfern version; regenerated on next launch profile={} from={} to={version}",
profile.id,
profile.version
);
}
@@ -1088,7 +1120,7 @@ impl ProfileManager {
// Emit profile update event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
if profile.browser == "wayfern" {
@@ -1149,7 +1181,7 @@ impl ProfileManager {
// Emit profile group assignment event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(())
@@ -1192,7 +1224,7 @@ impl ProfileManager {
// Emit profile tags update event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -1224,7 +1256,7 @@ impl ProfileManager {
// Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -1262,7 +1294,7 @@ impl ProfileManager {
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -1294,7 +1326,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -1322,11 +1354,11 @@ impl ProfileManager {
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
log::warn!("Event emit failed event=profile-updated err=\"{e}\"");
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -1354,7 +1386,7 @@ impl ProfileManager {
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -1381,7 +1413,7 @@ impl ProfileManager {
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -1532,7 +1564,7 @@ impl ProfileManager {
self.save_profile(&new_profile)?;
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(new_profile)
@@ -1613,15 +1645,11 @@ impl ProfileManager {
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Wayfern configuration updated for profile '{}' (ID: {}).",
profile.name,
profile_id
);
log::info!("Wayfern config updated profile={profile_id}");
// Emit profile config update event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(())
@@ -1689,12 +1717,12 @@ impl ProfileManager {
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
log::warn!("Event emit failed event=profile-updated err=\"{e}\"");
}
// Emit general profiles changed event for profile list updates
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -1754,11 +1782,11 @@ impl ProfileManager {
}
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
log::warn!("Event emit failed event=profile-updated err=\"{e}\"");
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -1798,10 +1826,10 @@ impl ProfileManager {
}
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Failed to emit profile update event: {e}");
log::warn!("Event emit failed event=profile-updated err=\"{e}\"");
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Failed to emit profiles-changed event: {e}");
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(profile)
@@ -1882,11 +1910,6 @@ impl ProfileManager {
// Found a matching process
found_pid = Some(pid.as_u32());
is_running = true;
log::info!(
"Found browser process with PID: {} for profile: {}",
pid.as_u32(),
profile.name
);
break;
}
}
@@ -1916,8 +1939,13 @@ impl ProfileManager {
if merged.process_id != Some(pid) {
let old_pid = merged.process_id;
merged.process_id = Some(pid);
log::info!(
"Browser pid recorded profile={} pid={pid} old_pid={}",
merged.id,
old_pid.map_or_else(|| "-".to_string(), |p| p.to_string())
);
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to update profile with new PID: {e}");
log::warn!("Profile pid save failed profile={} err=\"{e}\"", merged.id);
}
// Re-point the worker at the browser's NEW identity. update_proxy_pid
// persists it, so a browser that re-execs mid-session doesn't leave
@@ -1932,11 +1960,12 @@ impl ProfileManager {
.set_browser_pid_for_profile(&merged.id.to_string(), pid);
}
}
} else if merged.process_id.is_some() {
} else if let Some(old_pid) = merged.process_id {
// Clear the PID if no process found
merged.process_id = None;
log::info!("Browser exit detected profile={} pid={old_pid}", merged.id);
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to clear profile PID: {e}");
log::warn!("Profile pid clear failed profile={} err=\"{e}\"", merged.id);
}
detected_stop = true;
}
@@ -1951,7 +1980,7 @@ impl ProfileManager {
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &merged) {
log::warn!("Warning: Failed to emit profile update event: {e}");
log::warn!("Event emit failed event=profile-updated err=\"{e}\"");
}
}
@@ -1993,7 +2022,7 @@ impl ProfileManager {
let old_pid = latest.process_id;
latest.process_id = wayfern_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Wayfern profile with process info: {e}");
log::warn!("Profile pid save failed profile={} err=\"{e}\"", latest.id);
}
// Same contract as the Camoufox path: the worker reaps itself off
// the identity on disk, so a PID change must reach disk too.
@@ -2010,13 +2039,16 @@ impl ProfileManager {
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
log::warn!("Event emit failed event=profile-updated err=\"{e}\"");
}
log::info!(
"Wayfern process has started for profile '{}' with PID: {:?}",
profile.name,
wayfern_process.processId
"Browser pid recorded profile={} pid={} old_pid={}",
latest.id,
wayfern_process
.processId
.map_or_else(|| "-".to_string(), |p| p.to_string()),
old_pid.map_or_else(|| "-".to_string(), |p| p.to_string())
);
}
}
@@ -2046,10 +2078,11 @@ impl ProfileManager {
None => profile.clone(),
};
if latest.process_id.is_some() {
if let Some(old_pid) = latest.process_id {
latest.process_id = None;
log::info!("Browser exit detected profile={} pid={old_pid}", latest.id);
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to clear Wayfern profile process info: {e}");
log::warn!("Profile pid clear failed profile={} err=\"{e}\"", latest.id);
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
@@ -2060,7 +2093,7 @@ impl ProfileManager {
crate::wayfern_identity_storage::request_conversion_pass();
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
log::warn!("Event emit failed event=profile-updated err=\"{e}\"");
}
}
}
+9 -9
View File
@@ -305,7 +305,7 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul
}
if let Err(e) = std::fs::remove_dir_all(&backup) {
log::warn!(
"Failed to remove plaintext backup at {}: {e}",
"Plaintext backup removal failed path=\"{}\" err=\"{e}\"",
backup.display()
);
}
@@ -529,7 +529,7 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
}
if let Err(e) = std::fs::remove_dir_all(&backup) {
log::warn!(
"Failed to remove encrypted backup at {}: {e}",
"Encrypted backup removal failed path=\"{}\" err=\"{e}\"",
backup.display()
);
}
@@ -607,7 +607,7 @@ pub fn prepare_for_launch(profile: &crate::profile::BrowserProfile) -> Result<Pa
} else {
// Wipe any stale contents and re-decrypt.
if let Err(e) = clear_dir_contents(&ephemeral) {
log::warn!("Failed to clear stale ephemeral contents: {e}");
log::warn!("Stale decrypted copy clear failed profile={id} err=\"{e}\"");
}
decrypt_profile_dir(&key, &encrypted_dir, &ephemeral).map_err(|e| match e {
crate::profile::encryption::PasswordError::WrongPassword => err_code("INCORRECT_PASSWORD"),
@@ -693,18 +693,18 @@ pub fn complete_after_quit_blocking(
&snapshot,
) {
Ok(n) => {
log::info!("Re-encrypted {n} changed file(s) for profile {id}");
log::info!(
"Profile re-encrypted profile={id} changed_files={n} keep_decrypted={keep_decrypted}"
);
Some(n)
}
Err(e) => {
log::error!("Re-encryption failed for profile {id}: {e}");
log::error!("Profile re-encryption failed profile={id} err=\"{e}\"");
None
}
};
if keep_decrypted {
log::info!("Keeping decrypted copy of profile {id} in RAM (per settings)");
} else {
if !keep_decrypted {
drop_cached_key(&id);
if let Ok(mut guard) = POPULATED_EPHEMERAL.lock() {
guard.remove(&id);
@@ -737,7 +737,7 @@ pub async fn complete_after_quit_and_wait(
tokio::task::spawn_blocking(move || complete_after_quit_blocking(&profile, keep_decrypted))
.await
.unwrap_or_else(|e| {
log::error!("complete_after_quit_and_wait join error: {e}");
log::error!("Profile re-encryption task panicked err=\"{e}\"");
None
})
}
+4 -1
View File
@@ -253,7 +253,10 @@ pub fn export_to(
// A browser file can vanish between the walk and the read; that is not
// a reason to fail an export of everything else.
Err(e) => {
log::warn!("Skipping {} in the export: {e}", absolute.display());
log::warn!(
"Profile export skipped a file path=\"{}\" err=\"{e}\"",
absolute.display()
);
continue;
}
};
+13 -17
View File
@@ -151,7 +151,10 @@ pub fn prune_cache_dirs(data_dir: &Path) -> Vec<PathBuf> {
}
match erase_dir(&dir) {
Ok(()) => removed.push(dir),
Err(e) => log::warn!("Could not prune cache dir {}: {e}", dir.display()),
Err(e) => log::warn!(
"Cache dir prune failed path=\"{}\" err=\"{e}\"",
dir.display()
),
}
}
removed
@@ -202,7 +205,7 @@ pub fn move_dir(from: &Path, to: &Path) -> std::io::Result<()> {
Ok(()) => Ok(()),
Err(rename_error) => {
log::info!(
"Rename of {} failed ({rename_error}); copying instead",
"Trash move fell back to copy path=\"{}\" rename_err=\"{rename_error}\"",
from.display()
);
if let Err(copy_error) = copy_dir_recursive(from, to) {
@@ -240,10 +243,9 @@ pub fn trash_profile(
if !profile.password_protected {
let removed = prune_cache_dirs(&source_dir.join(DATA_DIR));
if !removed.is_empty() {
log::info!(
"Pruned {} cache director{} from profile {id} before trashing",
removed.len(),
if removed.len() == 1 { "y" } else { "ies" }
log::debug!(
"Cache dirs pruned before trashing profile={id} dirs={}",
removed.len()
);
}
}
@@ -298,7 +300,7 @@ pub fn list_entries(trash_root: &Path) -> Vec<(BrowserProfile, TrashManifest)> {
match read_entry(trash_root, &id) {
Ok(found) => Some(found),
Err(e) => {
log::warn!("Skipping unreadable trash entry {id}: {e}");
log::warn!("Trash entry unreadable, skipped entry={id} err=\"{e}\"");
None
}
}
@@ -377,10 +379,7 @@ pub fn restore_profile(
if target_dir.exists() {
// Nothing registered lives here (a registered profile has metadata.json
// and would have been caught above), so this is leftover garbage.
log::warn!(
"Removing stale directory {} before restoring profile {profile_id}",
target_dir.display()
);
log::warn!("Removing stale directory before restore profile={profile_id}");
fs::remove_dir_all(&target_dir).map_err(err_internal)?;
}
@@ -437,7 +436,7 @@ pub fn purge_expired(trash_root: &Path, now: u64, retention_days: u32) -> Vec<St
match purge_entry(trash_root, &id) {
Ok(()) => Some(id),
Err(e) => {
log::warn!("Could not purge expired trash entry {id}: {e}");
log::warn!("Expired trash purge failed entry={id} err=\"{e}\"");
None
}
}
@@ -475,10 +474,7 @@ pub fn start_expiry_sweeper() {
.await
.unwrap_or(0);
if purged > 0 {
log::info!(
"Purged {purged} expired trash entr{}",
if purged == 1 { "y" } else { "ies" }
);
log::info!("Expired trash purged entries={purged}");
}
}
});
@@ -524,7 +520,7 @@ pub async fn restore_trashed_profile(
profile = current;
}
}
Err(e) => log::warn!("Restored profile {profile_id} keeps sync off: {e}"),
Err(e) => log::warn!("Restored profile keeps sync off profile={profile_id} err=\"{e}\""),
}
}
+1 -1
View File
@@ -158,7 +158,7 @@ fn save(log: &GenerationLog) {
std::fs::rename(&tmp, &path)
});
if let Err(e) = written {
log::warn!("Could not save the profile generation window: {e}");
log::warn!("Profile generation window save failed err=\"{e}\"");
}
}
+8 -2
View File
@@ -265,7 +265,10 @@ fn copy_dir(
// A store we cannot snapshot is a store we must not copy: a torn
// copy is deleted by Chromium on open, which looks identical to
// "the import silently lost my data".
log::warn!("Skipping unreadable store {}: {e}", source_path.display());
log::warn!(
"Import skipped an unreadable store path=\"{}\" err=\"{e}\"",
source_path.display()
);
outcome.unreadable_stores.push(name.to_string());
continue;
}
@@ -274,7 +277,10 @@ fn copy_dir(
match fs::copy(&source_path, &dest_path) {
Ok(bytes) => outcome.bytes_copied += bytes,
Err(e) => log::warn!("Failed to copy {}: {e}", source_path.display()),
Err(e) => log::warn!(
"Import file copy failed path=\"{}\" err=\"{e}\"",
source_path.display()
),
}
}
+9 -5
View File
@@ -87,12 +87,14 @@ pub fn recover_source_keys(
match macos_keychain_password(brand) {
Ok(Some(password)) => {
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(&password, MAC_ITERATIONS)));
log::info!("Recovered os_crypt password for '{brand} Safe Storage'");
log::info!("Import key recovered source=keychain item=\"{brand} Safe Storage\"");
break;
}
Ok(None) => continue,
Err(e) => {
log::warn!("Keychain lookup for '{brand} Safe Storage' failed: {e}");
log::warn!(
"Import key lookup failed source=keychain item=\"{brand} Safe Storage\" err=\"{e}\""
);
break;
}
}
@@ -106,7 +108,7 @@ pub fn recover_source_keys(
match windows_local_state_key(dir) {
Ok(Some(key)) => keyring.v10 = Some(CryptoKey::Aes256Gcm(key)),
Ok(None) => {}
Err(e) => log::warn!("DPAPI key recovery failed: {e}"),
Err(e) => log::warn!("Import key lookup failed source=dpapi err=\"{e}\""),
}
if windows_has_app_bound_key(dir) {
// Recorded up front: the cookie store will be full of `v20` records
@@ -132,12 +134,14 @@ pub fn recover_source_keys(
&password,
POSIX_ITERATIONS,
)));
log::info!("Recovered os_crypt secret for '{brand} Safe Storage'");
log::info!("Import key recovered source=secret_service item=\"{brand} Safe Storage\"");
break;
}
Ok(None) => continue,
Err(e) => {
log::warn!("Secret service lookup for '{brand} Safe Storage' failed: {e}");
log::warn!(
"Import key lookup failed source=secret_service item=\"{brand} Safe Storage\" err=\"{e}\""
);
break;
}
}
+1 -1
View File
@@ -221,7 +221,7 @@ pub fn repair_legacy_layout(user_data_dir: &Path) -> Result<bool, String> {
let _ = os_crypt::TargetKey::ensure(user_data_dir);
log::info!(
"Repaired legacy import layout at {} (moved profile content into {INITIAL_PROFILE_DIR}/)",
"Legacy import layout repaired: content moved into {INITIAL_PROFILE_DIR}/ path=\"{}\"",
user_data_dir.display()
);
Ok(true)
+1 -1
View File
@@ -262,7 +262,7 @@ impl TargetKey {
// truncated write). Replacing it is safe only because import always
// re-encrypts into whatever key we end up with.
log::warn!(
"Replacing unusable {KEY_FILE_NAME} ({} bytes) at {}",
"Replacing unusable {KEY_FILE_NAME} bytes={} path=\"{}\"",
existing.len(),
key_file.display()
);
+4 -1
View File
@@ -80,7 +80,10 @@ fn open_rw(path: &Path) -> Option<Connection> {
match Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE) {
Ok(conn) => Some(conn),
Err(e) => {
log::warn!("Could not open {} for re-encryption: {e}", path.display());
log::warn!(
"Import store open failed path=\"{}\" err=\"{e}\"",
path.display()
);
None
}
}
+17 -8
View File
@@ -710,6 +710,7 @@ impl ProfileImporter {
.map(|p| p.name.to_lowercase())
.collect();
let started = std::time::Instant::now();
let total = items.len();
let mut results = Vec::new();
let mut imported_count = 0usize;
@@ -793,18 +794,30 @@ impl ProfileImporter {
emit_import_progress(total, completed, index, &final_name, "failed");
// The name was reserved but the import failed — free it again.
taken_names.remove(&final_name.to_lowercase());
let error = error_to_code_string(e);
// The text can quote the profile name and the source paths.
let logged =
crate::log_redaction::text(&error.replace(&format!("'{final_name}'"), "'<name>'"));
log::warn!(
"Profile import item failed index={index} source_browser={} err=\"{logged}\"",
item.browser_type
);
results.push(ProfileImportItemResult {
name: final_name,
source_path: item.source_path,
status: "failed".to_string(),
profile_id: None,
error: Some(error_to_code_string(e)),
error: Some(error),
report: None,
});
}
}
}
log::info!(
"Profile import finished items={total} imported={imported_count} skipped={skipped_count} failed={failed_count} elapsed_ms={}",
started.elapsed().as_millis()
);
Ok(ProfileImportBatchResult {
imported_count,
skipped_count,
@@ -961,7 +974,7 @@ impl ProfileImporter {
if config.fingerprint.is_none() && config.identity_id.is_none() {
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
id: profile_id,
name: new_profile_name.to_string(),
browser: mapped.to_string(),
version: version.clone(),
@@ -1086,16 +1099,12 @@ impl ProfileImporter {
// the exact symptom the old layout bug produced, so it is worth a loud
// line in the log rather than a silent success.
log::warn!(
"Imported profile '{}' from '{}' carried no readable data (warnings: {:?})",
new_profile_name,
source_path.display(),
"Profile imported with no readable data profile={profile_id} source_browser={browser_type} warnings={:?}",
report.warnings
);
} else {
log::info!(
"Imported profile '{}' from '{}': {} cookies, {} passwords, {} history entries ({} unrecoverable secrets, warnings: {:?})",
new_profile_name,
source_path.display(),
"Profile imported profile={profile_id} source_browser={browser_type} cookies={} passwords={} history={} unrecoverable={} warnings={:?}",
report.cookies_migrated,
report.logins_migrated,
report.history_entries,
+1 -1
View File
@@ -158,7 +158,7 @@ pub async fn apply_pairs(
.into_iter()
.find(|profile| profile.id.to_string() == pair.profile_id),
Err(e) => {
log::warn!("Could not list profiles while distributing proxies: {e}");
log::warn!("Proxy distribution could not list profiles err=\"{e}\"");
None
}
};
+133 -105
View File
@@ -247,21 +247,60 @@ pub(crate) fn persist_browser_identity(proxy_id: &str, browser_pid: u32) -> bool
return false;
};
let Some(start_time) = crate::proxy_storage::resolve_process_start_time(browser_pid) else {
log::warn!("Could not resolve start time for browser PID {browser_pid} (proxy {proxy_id})");
log::warn!(
"Browser start time unknown, worker owner not recorded proxy_worker={proxy_id} browser_pid={browser_pid}"
);
return false;
};
cfg.browser_pid = Some(browser_pid);
cfg.browser_pid_start_time = Some(start_time);
if crate::proxy_storage::update_proxy_config(&cfg) {
log::info!("Recorded browser PID {browser_pid} on proxy config {proxy_id} for self-reaping");
log::debug!("Worker owner recorded proxy_worker={proxy_id} browser_pid={browser_pid}");
true
} else {
log::warn!("Failed to persist browser_pid {browser_pid} to proxy config {proxy_id}");
log::warn!("Worker owner write failed proxy_worker={proxy_id} browser_pid={browser_pid}");
false
}
}
/// A worker as it was just before the sidecar stopped it: the sidecar's own
/// output never reaches this log, so the stop line is written from here.
struct WorkerSnapshot {
pid: Option<u32>,
was_running: bool,
profile_id: Option<String>,
browser_pid: Option<u32>,
}
impl WorkerSnapshot {
fn take(proxy_id: &str) -> Self {
let config = crate::proxy_storage::get_proxy_config(proxy_id);
let pid = config.as_ref().and_then(|config| config.pid);
Self {
pid,
was_running: pid.is_some_and(crate::proxy_storage::is_process_running),
profile_id: config.as_ref().and_then(|config| config.profile_id.clone()),
browser_pid: config.as_ref().and_then(|config| config.browser_pid),
}
}
fn log_stopped(&self, proxy_id: &str) {
let how = match self.pid {
None => "unknown",
Some(_) if !self.was_running => "already_gone",
Some(pid) if crate::proxy_storage::is_process_running(pid) => "still_running",
Some(_) => "terminated",
};
log::info!(
"Proxy worker stopped id={proxy_id} pid={} profile={} browser_pid={} reason=profile_stop how={how}",
self.pid.map(|pid| pid.to_string()).unwrap_or_else(|| "unknown".to_string()),
self.profile_id.as_deref().unwrap_or("none"),
self.browser_pid.map(|pid| pid.to_string()).unwrap_or_else(|| "none".to_string()),
);
}
}
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::sync::is_sync_configured();
@@ -333,7 +372,7 @@ impl ProxyManager {
// Load stored proxies on initialization
if let Err(e) = manager.load_stored_proxies() {
log::warn!("Failed to load stored proxies: {e}");
log::warn!("Stored proxies load failed err=\"{e}\"");
}
manager
@@ -414,12 +453,9 @@ impl ProxyManager {
let proxies_dir = self.get_proxies_dir();
if !proxies_dir.exists() {
log::debug!("Proxies directory does not exist: {:?}", proxies_dir);
return Ok(()); // No proxies directory yet
}
log::debug!("Loading stored proxies from: {:?}", proxies_dir);
let mut stored_proxies = self.stored_proxies.lock().unwrap();
let mut loaded_count = 0;
let mut error_count = 0;
@@ -433,32 +469,30 @@ impl ProxyManager {
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<StoredProxy>(&content) {
Ok(proxy) => {
log::debug!("Loaded stored proxy: {} ({})", proxy.name, proxy.id);
log::debug!("Loaded stored proxy proxy={}", proxy.id);
stored_proxies.insert(proxy.id.clone(), proxy);
loaded_count += 1;
}
Err(e) => {
log::warn!(
"Failed to parse proxy file {:?} as StoredProxy: {}",
path,
e
"Stored proxy parse failed path={} err=\"{e}\"",
path.display()
);
error_count += 1;
}
},
Err(e) => {
log::warn!("Failed to read proxy file {:?}: {}", path, e);
log::warn!(
"Stored proxy read failed path={} err=\"{e}\"",
path.display()
);
error_count += 1;
}
}
}
}
log::info!(
"Loaded {} stored proxies ({} errors)",
loaded_count,
error_count
);
log::debug!("Loaded stored proxies count={loaded_count} errors={error_count}");
Ok(())
}
@@ -517,7 +551,7 @@ impl ProxyManager {
history.entries
}
Err(e) => {
log::warn!("Failed to parse proxy check history {path:?}: {e}");
log::warn!("Proxy check history parse failed proxy={proxy_id} err=\"{e}\"");
Vec::new()
}
}
@@ -530,7 +564,7 @@ impl ProxyManager {
let dir = self.get_proxy_history_dir();
if let Err(e) = fs::create_dir_all(&dir) {
log::warn!("Failed to create the proxy check history directory: {e}");
log::warn!("Proxy check history dir create failed err=\"{e}\"");
return;
}
match serde_json::to_string_pretty(&ProxyCheckHistory { entries }) {
@@ -541,10 +575,10 @@ impl ProxyManager {
&self.get_proxy_history_file_path(proxy_id),
content.as_bytes(),
) {
log::warn!("Failed to write the proxy check history: {e}");
log::warn!("Proxy check history write failed proxy={proxy_id} err=\"{e}\"");
}
}
Err(e) => log::warn!("Failed to serialize the proxy check history: {e}"),
Err(e) => log::warn!("Proxy check history serialize failed proxy={proxy_id} err=\"{e}\""),
}
}
@@ -604,12 +638,12 @@ impl ProxyManager {
}
if let Err(e) = self.save_proxy(&stored_proxy) {
log::warn!("Failed to save proxy: {e}");
log::error!("Proxy save failed proxy={} err=\"{e}\"", stored_proxy.id);
}
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
if stored_proxy.sync_enabled {
@@ -640,10 +674,10 @@ impl ProxyManager {
drop(stored_proxies);
if let Err(e) = self.save_proxy(&updated) {
log::warn!("Failed to save cloud proxy: {e}");
log::warn!("Cloud proxy save failed err=\"{e}\"");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
Ok(updated)
} else {
@@ -668,10 +702,10 @@ impl ProxyManager {
drop(stored_proxies);
if let Err(e) = self.save_proxy(&cloud_proxy) {
log::warn!("Failed to save cloud proxy: {e}");
log::warn!("Cloud proxy save failed err=\"{e}\"");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
Ok(cloud_proxy)
}
@@ -686,10 +720,10 @@ impl ProxyManager {
if removed {
if let Err(e) = self.delete_proxy_file(CLOUD_PROXY_ID) {
log::warn!("Failed to delete cloud proxy file: {e}");
log::warn!("Cloud proxy file delete failed err=\"{e}\"");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
}
}
@@ -711,14 +745,14 @@ impl ProxyManager {
if !removed_ids.is_empty() {
for id in &removed_ids {
if let Err(e) = self.delete_proxy_file(id) {
log::warn!("Failed to delete cloud proxy file {id}: {e}");
log::warn!("Cloud proxy file delete failed proxy={id} err=\"{e}\"");
}
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
if let Err(e) = events::emit_empty("stored-proxies-changed") {
log::error!("Failed to emit stored-proxies-changed event: {e}");
log::warn!("Event emit failed event=stored-proxies-changed err=\"{e}\"");
}
}
}
@@ -864,11 +898,11 @@ impl ProxyManager {
}
if let Err(e) = self.save_proxy(&stored_proxy) {
log::warn!("Failed to save location proxy: {e}");
log::error!("Proxy save failed proxy={} err=\"{e}\"", stored_proxy.id);
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
Ok(stored_proxy)
@@ -931,15 +965,18 @@ impl ProxyManager {
for proxy in &proxies_to_save {
if let Err(e) = self.save_proxy(proxy) {
log::warn!("Failed to save updated derived proxy {}: {e}", proxy.id);
log::warn!("Derived proxy save failed proxy={} err=\"{e}\"", proxy.id);
}
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
log::debug!("Updated {} cloud-derived proxies", proxies_to_save.len());
log::debug!(
"Updated cloud-derived proxies count={}",
proxies_to_save.len()
);
}
}
@@ -1031,12 +1068,12 @@ impl ProxyManager {
};
if let Err(e) = self.save_proxy(&updated_proxy) {
log::warn!("Failed to save proxy: {e}");
log::error!("Proxy save failed proxy={} err=\"{e}\"", updated_proxy.id);
}
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
if updated_proxy.sync_enabled {
@@ -1119,7 +1156,7 @@ impl ProxyManager {
}
if let Err(e) = self.delete_proxy_file(proxy_id) {
log::warn!("Failed to delete proxy file: {e}");
log::error!("Proxy file delete failed proxy={proxy_id} err=\"{e}\"");
}
// If sync was enabled, also delete from S3
@@ -1130,13 +1167,13 @@ impl ProxyManager {
match crate::sync::SyncEngine::create_from_settings(&app_handle_clone).await {
Ok(engine) => {
if let Err(e) = engine.delete_proxy(&proxy_id_owned).await {
log::warn!("Failed to delete proxy {} from sync: {}", proxy_id_owned, e);
log::warn!("Proxy sync delete failed proxy={proxy_id_owned} err=\"{e}\"");
} else {
log::info!("Proxy {} deleted from S3 sync storage", proxy_id_owned);
log::info!("Proxy deleted from sync proxy={proxy_id_owned}");
}
}
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
log::debug!("Sync not configured, remote delete skipped err=\"{e}\"");
}
}
});
@@ -1144,7 +1181,7 @@ impl ProxyManager {
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
Ok(())
@@ -1301,7 +1338,7 @@ impl ProxyManager {
Err(_) => "timed out".to_string(),
};
log::warn!("TLS handshake with upstream proxy {addr} failed: {detail}");
log::warn!("Upstream TLS handshake failed err=\"{detail}\"");
Err(
serde_json::json!({
"code": "PROXY_TLS_HANDSHAKE_FAILED",
@@ -1375,7 +1412,9 @@ impl ProxyManager {
// killed mid-check the worker follows instead of idling until the
// next app launch.
if !persist_browser_identity(&config_id, std::process::id()) {
log::warn!("Failed to tag check worker {config_id} with app PID for self-expiry");
log::warn!(
"Check worker owner not recorded, it outlives an app crash proxy_worker={config_id}"
);
}
// Wrap in a timeout so the check worker doesn't stay alive indefinitely
// if the upstream is slow or unreachable.
@@ -1390,17 +1429,20 @@ impl ProxyManager {
))
});
// Always stop the worker — even if the check failed or timed out
let _ = crate::proxy_runner::stop_proxy_process(&config_id).await;
let _ = crate::proxy_runner::stop_proxy_process_because(&config_id, "check_done").await;
result
}
Err(err_msg) => {
if xray_worker_id.is_some() {
log::warn!("Local proxy worker failed to start in front of Xray-core: {err_msg}");
log::warn!(
"Proxy worker start failed in front of Xray err=\"{}\"",
crate::log_redaction::text(&err_msg)
);
Err(ip_utils::IpError::Network(err_msg))
} else {
log::warn!(
"Proxy worker failed to start ({}), falling back to direct check",
err_msg
"Proxy worker start failed, checking directly err=\"{}\"",
crate::log_redaction::text(&err_msg)
);
// reqwest cannot parse Donut's own `httpstls` scheme; without the
// rewrite every fallback check on that type dies as "Invalid proxy"
@@ -1910,6 +1952,7 @@ impl ProxyManager {
// the returned ProxySettings.proxy_type so the caller formats the right local proxy URL scheme.
local_protocol: &str,
) -> Result<ProxySettings, String> {
let started = std::time::Instant::now();
if let Some(name) = profile_id {
// Check if we have an active proxy recorded for this profile
let maybe_existing_id = {
@@ -2126,7 +2169,8 @@ impl ProxyManager {
// The detached worker is already running with its config on disk, but
// it was never registered in active_proxies — no cleanup pass could
// ever find it, so it must be killed before this error propagates.
let _ = crate::proxy_runner::stop_proxy_process(&proxy_info.id).await;
let _ =
crate::proxy_runner::stop_proxy_process_because(&proxy_info.id, "start_timeout").await;
return Err(format!(
"Local proxy on 127.0.0.1:{} did not become ready in time",
proxy_info.local_port
@@ -2134,6 +2178,19 @@ impl ProxyManager {
}
}
log::info!(
"Proxy worker started id={} pid={} port={} profile={} upstream={} elapsed_ms={}",
proxy_info.id,
crate::proxy_storage::get_proxy_config(&proxy_info.id)
.and_then(|config| config.pid)
.map(|pid| pid.to_string())
.unwrap_or_else(|| "unknown".to_string()),
proxy_info.local_port,
profile_id.unwrap_or("none"),
crate::log_redaction::Plain(&proxy_info.upstream_type.to_lowercase()),
started.elapsed().as_millis()
);
// Store the proxy info
{
let mut proxies = self.active_proxies.lock().unwrap();
@@ -2175,6 +2232,7 @@ impl ProxyManager {
None => return Ok(()), // No proxy to stop
}
};
let worker = WorkerSnapshot::take(&proxy_id);
// Stop the proxy using the donut-proxy binary
let proxy_cmd = app_handle
@@ -2192,13 +2250,14 @@ impl ProxyManager {
match proxy_cmd.output().await {
Ok(output) if !output.status.success() => {
log::warn!(
"Proxy stop error: {}",
String::from_utf8_lossy(&output.stderr)
"Proxy worker stop command failed id={proxy_id} stderr={:?}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(_) => {}
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
Err(e) => log::warn!("Proxy worker stop command did not run id={proxy_id} err=\"{e}\""),
}
worker.log_stopped(&proxy_id);
// Clear profile-to-proxy mapping if it references this proxy
if let Some(id) = profile_id {
@@ -2212,7 +2271,7 @@ impl ProxyManager {
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
Ok(())
@@ -2248,6 +2307,7 @@ impl ProxyManager {
self.stop_proxy(app_handle, pid).await
} else {
// Proxy not found in active_proxies, try to stop it directly by ID
let worker = WorkerSnapshot::take(&proxy_id);
let proxy_cmd = app_handle
.shell()
.sidecar("donut-proxy")
@@ -2261,13 +2321,14 @@ impl ProxyManager {
match proxy_cmd.output().await {
Ok(output) if !output.status.success() => {
log::warn!(
"Proxy stop error: {}",
String::from_utf8_lossy(&output.stderr)
"Proxy worker stop command failed id={proxy_id} stderr={:?}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(_) => {}
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
Err(e) => log::warn!("Proxy worker stop command did not run id={proxy_id} err=\"{e}\""),
}
worker.log_stopped(&proxy_id);
// Clear profile-to-proxy mapping
let mut map = self.profile_active_proxy_ids.lock().unwrap();
@@ -2275,7 +2336,7 @@ impl ProxyManager {
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
Ok(())
@@ -2311,8 +2372,7 @@ impl ProxyManager {
if !persist_browser_identity(&proxy_id, new_pid) {
log::warn!(
"Re-keyed proxy {proxy_id} to browser PID {new_pid} in memory but could not persist it; \
the detached worker is still watching the previous process"
"Worker owner not persisted, worker still watches the old browser proxy_worker={proxy_id} old_browser_pid={old_pid} browser_pid={new_pid}"
);
}
Ok(())
@@ -2341,7 +2401,7 @@ impl ProxyManager {
/// back to the newest matching config on disk: after a GUI restart the map is
/// empty, but a browser (and its worker) launched by the PREVIOUS GUI can
/// still be running, and that worker's owner identity must stay refreshable.
fn resolve_proxy_id_for_profile(&self, profile_id: &str) -> Option<String> {
pub(crate) fn resolve_proxy_id_for_profile(&self, profile_id: &str) -> Option<String> {
if let Some(id) = self
.profile_active_proxy_ids
.lock()
@@ -2399,11 +2459,6 @@ impl ProxyManager {
// Grace period: don't clean up proxies created in the last 120 seconds
// This prevents race conditions during startup (increased from 60 to 120 for safety)
if proxy_age < 120 {
log::debug!(
"Skipping cleanup of proxy {} - too new (age: {}s)",
config.id,
proxy_age
);
return false;
}
@@ -2412,29 +2467,14 @@ impl ProxyManager {
if let Some(proxy_pid) = config.pid {
// Check if proxy process is actually dead
if !is_process_running(proxy_pid) {
// Proxy process is dead, clean up the config file
log::info!(
"Proxy {} process (PID {}) is dead, will clean up config",
config.id,
proxy_pid
);
return true;
}
// Proxy process is running - leave it alone
log::debug!(
"Skipping cleanup of proxy {} - process (PID {}) is still running",
config.id,
proxy_pid
);
return false;
}
// No PID in config - can't verify if process is dead
// Be conservative: don't clean up (might be starting up or PID not set yet)
log::debug!(
"Skipping cleanup of proxy {} - no PID in config (might be starting up)",
config.id
);
false
})
.collect::<Vec<_>>()
@@ -2443,8 +2483,10 @@ impl ProxyManager {
// Clean up orphaned config files (proxy process is dead)
for config in orphaned_configs {
log::info!(
"Cleaning up orphaned proxy config: {} (proxy process is dead)",
config.id
"Proxy worker exited, record removed id={} pid={} profile={}",
config.id,
config.pid.unwrap_or_default(),
config.profile_id.as_deref().unwrap_or("none")
);
use crate::proxy_storage::delete_proxy_config;
delete_proxy_config(&config.id);
@@ -2475,13 +2517,8 @@ impl ProxyManager {
let proxy_age = crate::proxy_storage::proxy_config_age_secs(&config.id);
if proxy_age > 300 {
log::info!(
"Killing stale profileless proxy {} (PID {}, age {}s)",
config.id,
pid,
proxy_age
);
let _ = crate::proxy_runner::stop_proxy_process(&config.id).await;
let _ =
crate::proxy_runner::stop_proxy_process_because(&config.id, "stale_check_worker").await;
}
}
}
@@ -2578,12 +2615,6 @@ impl ProxyManager {
};
for (browser_pid, proxy_id, profile_id) in dead_browser_entries {
log::info!(
"Cleanup: browser PID {} is dead, stopping proxy worker {} (profile={:?})",
browser_pid,
proxy_id,
profile_id
);
{
let mut proxies = self.active_proxies.lock().unwrap();
// Re-check the entry still maps to the same proxy_id — another
@@ -2603,7 +2634,8 @@ impl ProxyManager {
map.remove(pid);
}
}
let _ = crate::proxy_runner::stop_proxy_process(&proxy_id).await;
let reason = format!("browser_exited({browser_pid})");
let _ = crate::proxy_runner::stop_proxy_process_because(&proxy_id, &reason).await;
}
}
}
@@ -2618,9 +2650,9 @@ impl ProxyManager {
if let Some(pid) = worker.pid {
if !is_process_running(pid) {
log::info!(
"Cleaning up orphaned VPN worker config: {} (process PID {} is dead)",
"VPN worker exited, record removed id={} pid={pid} vpn={}",
worker.id,
pid
worker.vpn_id
);
let _ = std::fs::remove_file(&worker.config_file_path);
delete_vpn_worker_config(&worker.id);
@@ -2638,10 +2670,6 @@ impl ProxyManager {
.pid
.is_some_and(|pid| !process_identity_matches(pid, worker.pid_start_time));
if dead || unstarted_worker_is_stale(&worker) {
log::info!(
"Cleaning up orphaned Xray-core worker config: {}",
worker.id
);
let _ = crate::xray_worker_runner::stop_xray_worker(&worker.id).await;
}
}
@@ -2649,7 +2677,7 @@ impl ProxyManager {
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
log::warn!("Event emit failed event=proxies-changed err=\"{e}\"");
}
Ok(dead_pids)
+55 -16
View File
@@ -42,7 +42,7 @@ fn prune_stale_proxy_logs(temp_dir: &Path, retain: usize) {
for (_, path) in logs.into_iter().skip(retain) {
if let Err(error) = std::fs::remove_file(&path) {
log::debug!(
"Failed to prune stale proxy log {}: {error}",
"Stale proxy log prune failed path={} err=\"{error}\"",
path.display()
);
}
@@ -216,7 +216,7 @@ pub(crate) async fn ensure_sidecar_version() -> Result<(), Box<dyn std::error::E
let executable = match find_sidecar_executable("donut-proxy") {
Ok(executable) => executable,
Err(e) => {
log::error!("Failed to locate donut-proxy for version verification: {e}");
log::error!("donut-proxy not found for version check err=\"{e}\"");
return Err(sidecar_version_mismatch_error());
}
};
@@ -234,7 +234,7 @@ pub(crate) async fn ensure_sidecar_version() -> Result<(), Box<dyn std::error::E
Ok(output) => output,
Err(e) => {
log::error!(
"Failed to run {} for version verification: {e}",
"donut-proxy version check failed to run path={} err=\"{e}\"",
executable.display()
);
return Err(sidecar_version_mismatch_error());
@@ -249,7 +249,7 @@ pub(crate) async fn ensure_sidecar_version() -> Result<(), Box<dyn std::error::E
}
log::error!(
"donut-proxy version mismatch: expected {}, got {:?}; status={}, stdout={:?}, stderr={:?}",
"donut-proxy version mismatch expected={} actual={:?} status=\"{}\" stdout={:?} stderr={:?}",
expected_version,
actual_version,
output.status,
@@ -289,8 +289,10 @@ pub async fn start_proxy_process_with_profile(
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
ensure_sidecar_version().await?;
let started = std::time::Instant::now();
let id = generate_proxy_id();
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
let upstream_kind = upstream_scheme(&upstream).to_string();
// Get available port if not specified
let local_port = port.unwrap_or_else(|| {
@@ -308,13 +310,6 @@ pub async fn start_proxy_process_with_profile(
.with_record_domains(record_domains);
save_proxy_config(&config)?;
// Log profile_id for debugging
if let Some(ref pid) = profile_id {
log::info!("Saved proxy config {} with profile_id: {}", id, pid);
} else {
log::info!("Saved proxy config {} without profile_id", id);
}
// Spawn proxy worker process in the background using std::process::Command
// This ensures proper process detachment on Unix systems
let exe = find_sidecar_executable("donut-proxy")?;
@@ -322,6 +317,7 @@ pub async fn start_proxy_process_with_profile(
let log_path = temp_dir.join(format!("donut-proxy-{id}.log"));
let log_file = crate::app_dirs::create_owner_only(&log_path);
prune_stale_proxy_logs(&temp_dir, RETAINED_PROXY_LOGS);
let spawned_pid: u32;
#[cfg(unix)]
{
@@ -341,7 +337,6 @@ pub async fn start_proxy_process_with_profile(
// Always log to file for diagnostics (both debug and release builds)
if let Ok(file) = log_file {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
cmd.stderr(Stdio::null());
@@ -366,6 +361,7 @@ pub async fn start_proxy_process_with_profile(
// Spawn detached process
let child = cmd.spawn()?;
let pid = child.id();
spawned_pid = pid;
// Store PID
{
@@ -414,7 +410,6 @@ pub async fn start_proxy_process_with_profile(
// Log to file for diagnostics (matching Unix behavior)
if let Ok(file) = log_file {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
cmd.stderr(Stdio::null());
@@ -427,6 +422,7 @@ pub async fn start_proxy_process_with_profile(
let child = cmd.spawn()?;
let pid = child.id();
spawned_pid = pid;
// Set high priority so the proxy is killed last under resource pressure
unsafe {
@@ -471,7 +467,11 @@ pub async fn start_proxy_process_with_profile(
.await
{
Ok(Ok(_stream)) => {
// Port is listening and accepting connections!
log::info!(
"Proxy worker started id={id} pid={spawned_pid} port={port} profile={} upstream={upstream_kind} elapsed_ms={}",
profile_id.as_deref().unwrap_or("none"),
started.elapsed().as_millis()
);
return Ok(updated_config);
}
Ok(Err(_)) | Err(_) => {
@@ -499,21 +499,31 @@ pub async fn start_proxy_process_with_profile(
// The detached worker (if it did spawn) would otherwise outlive this
// failed start with nothing tracking it — callers only get an error
// string, so this is the last place that can still kill it.
let _ = stop_proxy_process(&id).await;
let _ = stop_proxy_process_because(&id, "start_timeout").await;
return Err(format!("Proxy worker failed to start in time. {detail}").into());
}
}
}
pub async fn stop_proxy_process(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
stop_proxy_process_because(id, "requested").await
}
/// `reason` names why the worker is stopped, for its one stop line.
pub(crate) async fn stop_proxy_process_because(
id: &str,
reason: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let config = get_proxy_config(id);
let pid = PROXY_PROCESSES
.lock()
.unwrap()
.get(id)
.copied()
.or_else(|| get_proxy_config(id).and_then(|config| config.pid));
.or_else(|| config.as_ref().and_then(|config| config.pid));
if let Some(pid) = pid {
let was_running = is_process_running(pid);
// Kill the process
#[cfg(unix)]
{
@@ -545,12 +555,41 @@ pub async fn stop_proxy_process(id: &str) -> Result<bool, Box<dyn std::error::Er
// Delete the config file
delete_proxy_config(id);
let how = if !was_running {
"already_gone"
} else if is_process_running(pid) {
"still_running"
} else {
"terminated"
};
let profile = config
.as_ref()
.and_then(|config| config.profile_id.as_deref())
.unwrap_or("none");
let browser_pid = config
.as_ref()
.and_then(|config| config.browser_pid)
.map(|pid| pid.to_string())
.unwrap_or_else(|| "none".to_string());
log::info!(
"Proxy worker stopped id={id} pid={pid} profile={profile} browser_pid={browser_pid} reason={reason} how={how}"
);
return Ok(true);
}
Ok(false)
}
/// The scheme of an upstream URL, never its host or credentials.
fn upstream_scheme(upstream: &str) -> &str {
match upstream.split_once("://") {
Some((scheme, _)) => scheme,
None if upstream == "DIRECT" => "direct",
None => "unknown",
}
}
pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>> {
let configs = list_proxy_configs();
for config in configs {
+157 -127
View File
@@ -81,6 +81,10 @@ impl BlocklistMatcher {
}
}
pub fn domain_count(&self) -> usize {
self.domains.len()
}
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
Self::from_file_with_mode(path, false)
}
@@ -95,10 +99,9 @@ impl BlocklistMatcher {
.filter(|line| !line.starts_with('#') && !line.trim().is_empty())
.map(|line| line.trim().to_lowercase())
.collect();
log::info!(
"[blocklist] Loaded {} domains from {} (mode={})",
log::debug!(
"Blocklist loaded domains={} mode={} path=\"{path}\"",
domains.len(),
path,
if allowlist_mode { "allow" } else { "block" }
);
Ok(Self {
@@ -354,11 +357,13 @@ impl<S: AsyncWrite + Unpin> AsyncWrite for SocksHandshakeLogger<S> {
) -> Poll<io::Result<usize>> {
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = &result {
// Only the first two bytes: an RFC 1929 auth message carries the
// upstream username and password after them.
log::trace!(
"[socks-handshake:{}] -> {} byte(s): {:02x?}",
"[socks-handshake:{}] -> {} byte(s), head {:02x?}",
self.label,
n,
&buf[..*n]
&buf[..(*n).min(2)]
);
}
result
@@ -642,8 +647,7 @@ async fn read_http_response_buffer<S: AsyncRead + Unpin>(stream: &mut S) -> Buff
loop {
if response_buffer.len() > MAX_HTTP_HEADER_BUFFER {
log::warn!(
"HTTP response headers exceeded {} bytes without terminating; aborting read",
MAX_HTTP_HEADER_BUFFER
"Upstream response headers too large; read aborted limit_bytes={MAX_HTTP_HEADER_BUFFER}"
);
truncated = true;
break;
@@ -680,8 +684,7 @@ async fn read_http_response_buffer<S: AsyncRead + Unpin>(stream: &mut S) -> Buff
while read_so_far < remaining {
if response_buffer.len() >= MAX_HTTP_RESPONSE_BUFFER {
log::warn!(
"HTTP response body exceeded {} bytes; refusing to forward a truncated response",
MAX_HTTP_RESPONSE_BUFFER
"Upstream response too large; not forwarded limit_bytes={MAX_HTTP_RESPONSE_BUFFER}"
);
truncated = true;
break;
@@ -716,8 +719,7 @@ async fn read_http_response_buffer<S: AsyncRead + Unpin>(stream: &mut S) -> Buff
}
if response_buffer.len() >= MAX_HTTP_RESPONSE_BUFFER {
log::warn!(
"Chunked HTTP response exceeded {} bytes; refusing to forward a truncated response",
MAX_HTTP_RESPONSE_BUFFER
"Upstream chunked response too large; not forwarded limit_bytes={MAX_HTTP_RESPONSE_BUFFER}"
);
truncated = true;
break ChunkedState::Incomplete;
@@ -738,8 +740,7 @@ async fn read_http_response_buffer<S: AsyncRead + Unpin>(stream: &mut S) -> Buff
loop {
if response_buffer.len() >= MAX_HTTP_RESPONSE_BUFFER {
log::warn!(
"HTTP response exceeded {} bytes; refusing to forward a truncated response",
MAX_HTTP_RESPONSE_BUFFER
"Upstream response too large; not forwarded limit_bytes={MAX_HTTP_RESPONSE_BUFFER}"
);
truncated = true;
break;
@@ -757,7 +758,7 @@ async fn read_http_response_buffer<S: AsyncRead + Unpin>(stream: &mut S) -> Buff
}
}
Err(e) => {
log::error!("Error reading HTTP response: {}", e);
warn_upstream_failure("Upstream response read failed", &e.to_string(), None);
break;
}
}
@@ -785,7 +786,7 @@ async fn handle_http_via_socks4(
let upstream = match Url::parse(upstream_url) {
Ok(url) => url,
Err(e) => {
log::error!("Failed to parse SOCKS4 proxy URL: {}", e);
warn_upstream_failure("SOCKS4 upstream URL invalid", &e.to_string(), None);
let mut response = Response::new(Full::new(Bytes::from("Invalid proxy URL")));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
@@ -806,7 +807,7 @@ async fn handle_http_via_socks4(
match tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, TcpStream::connect(&socks_addr)).await {
Ok(Ok(stream)) => stream,
Ok(Err(e)) => {
log::error!("Failed to connect to SOCKS4 proxy {}: {}", socks_addr, e);
warn_upstream_failure("SOCKS4 upstream connect failed", &e.to_string(), None);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to connect to SOCKS4 proxy: {}",
e
@@ -815,7 +816,7 @@ async fn handle_http_via_socks4(
return Ok(response);
}
Err(_) => {
log::error!("Connect to SOCKS4 proxy {} timed out", socks_addr);
warn_upstream_failure("SOCKS4 upstream connect failed", "timed out", None);
let mut response =
Response::new(Full::new(Bytes::from("Connect to SOCKS4 proxy timed out")));
*response.status_mut() = StatusCode::GATEWAY_TIMEOUT;
@@ -840,7 +841,7 @@ async fn handle_http_via_socks4(
// Send SOCKS4 CONNECT request
if let Err(e) = socks_stream.write_all(&socks_request).await {
log::error!("Failed to send SOCKS4 CONNECT request: {}", e);
warn_upstream_failure("SOCKS4 upstream request send failed", &e.to_string(), None);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to send SOCKS4 request: {}",
e
@@ -859,7 +860,7 @@ async fn handle_http_via_socks4(
{
Ok(Ok(_)) => {}
Ok(Err(e)) => {
log::error!("Failed to read SOCKS4 response: {}", e);
warn_upstream_failure("SOCKS4 upstream reply read failed", &e.to_string(), None);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to read SOCKS4 response: {}",
e
@@ -868,7 +869,7 @@ async fn handle_http_via_socks4(
return Ok(response);
}
Err(_) => {
log::error!("SOCKS4 handshake response timed out");
warn_upstream_failure("SOCKS4 upstream reply read failed", "timed out", None);
let mut response = Response::new(Full::new(Bytes::from(
"SOCKS4 handshake response timed out",
)));
@@ -879,9 +880,10 @@ async fn handle_http_via_socks4(
// Check SOCKS4 response (second byte should be 0x5A for success)
if socks_response[1] != 0x5A {
log::error!(
"SOCKS4 connection failed, response code: {}",
socks_response[1]
warn_upstream_failure(
"SOCKS4 upstream rejected",
&format!("code={:#04x}", socks_response[1]),
None,
);
let mut response = Response::new(Full::new(Bytes::from("SOCKS4 connection failed")));
*response.status_mut() = StatusCode::BAD_GATEWAY;
@@ -947,7 +949,11 @@ async fn handle_http_via_socks4(
// Send HTTP request
if let Err(e) = socks_stream.write_all(http_request.as_bytes()).await {
log::error!("Failed to send HTTP request through SOCKS4: {}", e);
warn_upstream_failure(
"HTTP request send via SOCKS4 failed",
&e.to_string(),
Some(&domain),
);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to send HTTP request: {}",
e
@@ -959,7 +965,11 @@ async fn handle_http_via_socks4(
// Send body if present
if !body_bytes.is_empty() {
if let Err(e) = socks_stream.write_all(&body_bytes).await {
log::error!("Failed to send HTTP body through SOCKS4: {}", e);
warn_upstream_failure(
"HTTP body send via SOCKS4 failed",
&e.to_string(),
Some(&domain),
);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to send HTTP body: {}",
e
@@ -980,7 +990,7 @@ async fn handle_http_via_socks4(
{
Ok(buffer) => buffer,
Err(_) => {
log::error!("HTTP response via SOCKS4 timed out");
warn_upstream_failure("HTTP response via SOCKS4 failed", "timed out", None);
let mut response = Response::new(Full::new(Bytes::from("Upstream response timed out")));
*response.status_mut() = StatusCode::GATEWAY_TIMEOUT;
return Ok(response);
@@ -990,8 +1000,10 @@ async fn handle_http_via_socks4(
// A capped read holds only a prefix of the body. Forwarding it would hand the
// browser a complete-looking short response, so fail the request instead.
if buffered.truncated {
log::error!(
"HTTP response via SOCKS4 for {domain} exceeded the buffer cap; refusing to forward a truncated body"
warn_upstream_failure(
"HTTP response via SOCKS4 failed",
"exceeded the buffer cap; truncated body not forwarded",
None,
);
let mut response = Response::new(Full::new(Bytes::from(
"Upstream response too large to buffer",
@@ -1026,7 +1038,11 @@ async fn handle_http_via_socks4(
BufferedBody::AsSent => response_buffer[header_end..].to_vec(),
BufferedBody::Dechunked(body) => body,
BufferedBody::BrokenChunks => {
log::error!("Chunked HTTP response via SOCKS4 for {domain} did not decode");
warn_upstream_failure(
"HTTP response via SOCKS4 failed",
"chunked body did not decode",
None,
);
let mut response = Response::new(Full::new(Bytes::from("Malformed upstream response")));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
@@ -1138,7 +1154,11 @@ async fn handle_http_via_shadowsocks(
let mut response_buf = Vec::new();
if let Err(e) = stream.read_to_end(&mut response_buf).await {
log::warn!("SS read error (may be partial): {e}");
warn_upstream_failure(
"Shadowsocks response read failed; forwarding what arrived",
&e.to_string(),
Some(&domain),
);
}
if let Some(tracker) = get_traffic_tracker() {
@@ -1172,7 +1192,11 @@ async fn handle_http_via_shadowsocks(
match decode_chunked(raw_body, &mut cursor, &mut decoded) {
ChunkedState::Complete => decoded,
_ => {
log::error!("Chunked HTTP response via Shadowsocks for {domain} did not decode");
warn_upstream_failure(
"HTTP response via Shadowsocks failed",
"chunked body did not decode",
None,
);
let mut resp = Response::new(Full::new(Bytes::from("Malformed upstream response")));
*resp.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(resp);
@@ -1205,7 +1229,7 @@ async fn handle_http(
// Block if domain is in the DNS blocklist (before any connection)
if blocklist_matcher.is_blocked(&domain) {
log::debug!("[blocklist] Blocked HTTP request to {}", domain);
log::debug!("Blocklist blocked HTTP request host={domain}");
let mut response = Response::new(Full::new(Bytes::from("Blocked by DNS blocklist")));
*response.status_mut() = StatusCode::FORBIDDEN;
return Ok(response);
@@ -1249,7 +1273,7 @@ async fn handle_http(
match proxied_http_client(upstream) {
Ok(c) => c,
Err(e) => {
log::error!("Failed to create proxy client: {}", e);
warn_upstream_failure("Upstream HTTP client build failed", &e.to_string(), None);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Proxy configuration error: {}",
e
@@ -1318,9 +1342,13 @@ async fn handle_http(
let body = match response.bytes().await {
Ok(b) => b,
Err(e) => {
log::warn!("Failed to read response body from {domain}: {e}");
let mut error_response =
Response::new(Full::new(Bytes::from(format!("Response body failed: {e}"))));
let body = format!("Response body failed: {e}");
warn_upstream_failure(
"Upstream response body read failed",
&e.without_url().to_string(),
Some(&domain),
);
let mut error_response = Response::new(Full::new(Bytes::from(body)));
*error_response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(error_response);
}
@@ -1347,8 +1375,13 @@ async fn handle_http(
Ok(hyper_response)
}
Err(e) => {
log::error!("Request failed: {}", e);
let mut response = Response::new(Full::new(Bytes::from(format!("Request failed: {}", e))));
let body = format!("Request failed: {}", e);
warn_upstream_failure(
"Upstream HTTP request failed",
&e.without_url().to_string(),
Some(&domain),
);
let mut response = Response::new(Full::new(Bytes::from(body)));
*response.status_mut() = StatusCode::BAD_GATEWAY;
Ok(response)
}
@@ -1519,6 +1552,7 @@ pub async fn handle_proxy_connection(
}
}
let target_host = connect_target_host(&full_request);
if let Err(e) = handle_connect_from_buffer(
stream,
full_request,
@@ -1528,16 +1562,11 @@ pub async fn handle_proxy_connection(
)
.await
{
let msg = e.to_string();
if let Some(suppressed) = log_throttle(&msg) {
if suppressed > 0 {
log::warn!(
"CONNECT tunnel ended with error: {msg} ({suppressed} more suppressed in last 30s)"
);
} else {
log::warn!("CONNECT tunnel ended with error: {msg}");
}
}
warn_upstream_failure(
"CONNECT tunnel failed",
&e.to_string(),
target_host.as_deref(),
);
}
return;
}
@@ -1582,28 +1611,15 @@ pub fn redacted_upstream(upstream: &str) -> String {
}
pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
log::info!(
"Proxy worker starting, looking for config id: {}",
config.id
);
let started = std::time::Instant::now();
// Load the config from disk to get the latest state
let config = match crate::proxy_storage::get_proxy_config(&config.id) {
Some(c) => c,
None => {
log::error!("Config not found for id: {}", config.id);
return Err("Config not found".into());
}
};
log::info!(
"Found config: id={}, port={:?}, upstream={}, profile_id={:?}",
config.id,
config.local_port,
redacted_upstream(&config.upstream_url),
config.profile_id
);
// Initialize traffic tracker with profile ID if available.
// This can be called multiple times to update the tracker.
init_traffic_tracker(
@@ -1615,8 +1631,6 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
// Determine the bind address
let bind_addr = SocketAddr::from(([127, 0, 0, 1], config.local_port.unwrap_or(0)));
log::info!("Attempting to bind proxy server to {}", bind_addr);
// Bind to the port. Use SO_REUSEADDR so that a freshly-restarted worker
// can bind a port that the previous worker left in TIME_WAIT, and retry
// briefly to absorb transient races with the OS releasing the socket.
@@ -1631,11 +1645,8 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
Err(e) if attempts < 5 => {
attempts += 1;
let delay = std::time::Duration::from_millis(200 * u64::from(attempts));
log::warn!(
"listen() on {} failed (attempt {}/5): {}, retrying in {}ms",
bind_addr,
attempts,
e,
log::debug!(
"listen() failed; retrying addr={bind_addr} attempt={attempts}/5 delay_ms={} err=\"{e}\"",
delay.as_millis()
);
tokio::time::sleep(delay).await;
@@ -1647,11 +1658,8 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
Err(e) if attempts < 5 => {
attempts += 1;
let delay = std::time::Duration::from_millis(200 * u64::from(attempts));
log::warn!(
"bind() on {} failed (attempt {}/5): {}, retrying in {}ms",
bind_addr,
attempts,
e,
log::debug!(
"bind() failed; retrying addr={bind_addr} attempt={attempts}/5 delay_ms={} err=\"{e}\"",
delay.as_millis()
);
tokio::time::sleep(delay).await;
@@ -1662,8 +1670,6 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
};
let actual_port = listener.local_addr()?.port();
log::info!("Successfully bound to port {}", actual_port);
// Protocol served to the browser: "socks5" (Wayfern) or "http" (default).
let local_protocol = config.local_protocol_or_default();
let serve_socks5 = local_protocol == "socks5";
@@ -1680,7 +1686,6 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
));
if !crate::proxy_storage::update_proxy_config(&updated_config) {
log::error!("Failed to update proxy config");
return Err("Failed to update proxy config".into());
}
@@ -1690,12 +1695,6 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
Some(updated_config.upstream_url.clone())
};
log::info!(
"Proxy server listening on 127.0.0.1:{} (ready to accept connections)",
actual_port
);
log::info!("Proxy server entering accept loop - process should stay alive");
// Start a background task to write lightweight session snapshots for real-time updates
// These are much smaller than full stats and can be written frequently (~100 bytes every 2 seconds)
if let Some(tracker) = get_traffic_tracker() {
@@ -1717,7 +1716,7 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
// Write lightweight session snapshot (only current counters, ~100 bytes)
match tracker_clone.write_session_snapshot() {
Ok(()) => last_written = Some(snapshot),
Err(e) => log::debug!("Failed to write session snapshot: {}", e),
Err(e) => log::debug!("Session snapshot write failed err=\"{e}\""),
}
}
});
@@ -1774,14 +1773,14 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
last_flush_time = std::time::Instant::now();
}
Err(e) => {
log::error!("Failed to flush traffic stats: {}", e);
warn_throttled(&format!("Traffic stats flush failed err=\"{e}\""));
}
}
}
}
}));
if let Err(panic) = result {
log::error!("Panic caught in proxy traffic flush task; continuing: {panic:?}");
log::error!("Traffic flush task panicked; continuing err={panic:?}");
}
}
});
@@ -1806,6 +1805,14 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
{
let watch_id = config.id.clone();
let poll_interval = watchdog_poll_interval();
let exit_line = move |reason: &str, detail: String| {
log::info!(
"Worker exiting kind=proxy id={watch_id} pid={} reason={reason}{detail} uptime_s={}",
std::process::id(),
started.elapsed().as_secs()
);
};
let watch_id = config.id.clone();
std::thread::spawn(move || {
use crate::proxy_storage::SupervisorVerdict;
@@ -1825,20 +1832,18 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
consecutive_misses += 1;
if consecutive_misses >= 2 {
let owner = cfg.as_ref().and_then(|c| c.browser_pid).unwrap_or(0);
log::info!("Browser PID {owner} for config {watch_id} is gone; worker exiting");
exit_line("owner_gone", format!(" owner_pid={owner}"));
crate::proxy_storage::delete_proxy_config(&watch_id);
std::process::exit(0);
}
}
SupervisorVerdict::ExitNeverClaimed => {
log::info!(
"Config {watch_id} was never claimed by a browser within the launch window; worker exiting"
);
exit_line("never_claimed", String::new());
crate::proxy_storage::delete_proxy_config(&watch_id);
std::process::exit(0);
}
SupervisorVerdict::ExitConfigRemoved => {
log::info!("Proxy config {watch_id} was removed; worker exiting");
exit_line("config_removed", String::new());
std::process::exit(0);
}
}
@@ -1851,7 +1856,7 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
match BlocklistMatcher::from_file_with_mode(path, config.dns_allowlist_mode) {
Ok(m) => m,
Err(e) => {
log::error!("[blocklist] Failed to load from {}: {}", path, e);
log::warn!("Blocklist load failed; filtering off path=\"{path}\" err=\"{e}\"");
BlocklistMatcher::new()
}
}
@@ -1859,6 +1864,27 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
BlocklistMatcher::new()
};
log::info!(
"Worker started kind=proxy id={} pid={} listen=127.0.0.1:{actual_port} serve={local_protocol} upstream={} profile={} blocklist={} bypass_rules={} record_domains={} elapsed_ms={}",
config.id,
std::process::id(),
upstream_url
.as_deref()
.map_or_else(|| "direct".to_string(), redacted_upstream),
config.profile_id.as_deref().unwrap_or("-"),
match &config.blocklist_file {
Some(_) => format!(
"{}:{}",
if config.dns_allowlist_mode { "allow" } else { "block" },
blocklist_matcher.domain_count()
),
None => "none".to_string(),
},
config.bypass_rules.len(),
config.record_domains,
started.elapsed().as_millis()
);
// Bound concurrent connection handlers. A client retry-storm (e.g. a browser
// hammering CONNECT requests while DNS is failing) must not spawn unbounded
// tasks,
@@ -1895,7 +1921,7 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
}
}
Err(e) => {
log::error!("Error accepting connection: {:?}", e);
warn_throttled(&format!("Accept failed err=\"{e}\""));
// Continue accepting connections even if one fails
// Add a small delay to avoid busy-waiting on errors
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
@@ -1942,7 +1968,7 @@ async fn handle_connect_from_buffer(
// Block if domain is in the DNS blocklist (before any connection)
if blocklist_matcher.is_blocked(target_host) {
log::debug!("[blocklist] Blocked CONNECT tunnel to {}", target_host);
log::debug!("Blocklist blocked CONNECT host={target_host}");
let _ = client_stream
.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 24\r\n\r\nBlocked by DNS blocklist")
.await;
@@ -2127,38 +2153,41 @@ async fn dial_direct(host: &str, port: u16) -> Result<TcpStream, Box<dyn std::er
}
}
/// Rate-limit a repetitive log line keyed by `key`: returns `Some(suppressed)`
/// when the caller should emit (first time or after a 30s window, with the
/// count dropped since the last emit), or `None` to skip. Stops a connect/DNS
/// storm from writing the same WARN millions of times (the line that grew
/// worker logs to 100MB).
pub(crate) fn log_throttle(key: &str) -> Option<u64> {
fn throttle_map() -> &'static Mutex<HashMap<String, (std::time::Instant, u64)>> {
static M: OnceLock<Mutex<HashMap<String, (std::time::Instant, u64)>>> = OnceLock::new();
M.get_or_init(|| Mutex::new(HashMap::new()))
}
let map = throttle_map();
let mut guard = map.lock().unwrap();
if guard.len() > 2048 {
guard.retain(|_, (last, _)| last.elapsed() < std::time::Duration::from_secs(60));
}
let now = std::time::Instant::now();
match guard.get_mut(key) {
Some((last, suppressed)) => {
if now.duration_since(*last) >= std::time::Duration::from_secs(30) {
let dropped = *suppressed;
*last = now;
*suppressed = 0;
Some(dropped)
} else {
*suppressed += 1;
None
}
}
None => {
guard.insert(key.to_string(), (now, 0));
Some(0)
}
/// Host of a buffered `CONNECT host:port HTTP/1.1` request.
fn connect_target_host(request: &[u8]) -> Option<String> {
let line_end = request
.iter()
.position(|&b| b == b'\r' || b == b'\n')
.unwrap_or(request.len());
let line = String::from_utf8_lossy(&request[..line_end]);
let target = line.split_whitespace().nth(1)?;
let host = match target.strip_prefix('[') {
Some(rest) => rest.split(']').next()?,
None => target.rsplit_once(':').map_or(target, |(host, _)| host),
};
(!host.is_empty()).then(|| host.to_string())
}
/// Warn about a failed tunnel or proxied request, at most once per distinct
/// message every 30s. The target host is cut out of the message: these lines
/// reach the file, and the file must not list the sites a profile visited.
pub(crate) fn warn_upstream_failure(what: &str, err: &str, target_host: Option<&str>) {
let err = match target_host {
Some(host) if !host.is_empty() => err.replace(host, "<target>"),
_ => err.to_string(),
};
warn_throttled(&format!("{what} err=\"{err}\""));
}
/// `log::warn!` the line at most once every 30s, with the count it dropped.
/// Stops a connect/DNS storm from writing the same line millions of times.
pub(crate) fn warn_throttled(line: &str) {
static WORKER_LINES: crate::log_streak::Throttle =
crate::log_streak::Throttle::new(std::time::Duration::from_secs(30));
match WORKER_LINES.allow(line) {
Some(0) => log::warn!("{line}"),
Some(suppressed) => log::warn!("{line} suppressed_30s={suppressed}"),
None => {}
}
}
@@ -2250,7 +2279,8 @@ async fn connect_via_http_proxy<S: AsyncStream + 'static>(
if !response_headers.starts_with("HTTP/1.1 200") && !response_headers.starts_with("HTTP/1.0 200")
{
log::warn!(
// The caller warns with the status line; the target stays at debug.
log::debug!(
"Upstream CONNECT to {}:{} via {}:{} rejected: {}",
target_host,
target_port,
+6 -2
View File
@@ -91,14 +91,18 @@ pub async fn probe_udp_support(settings: &ProxySettings) -> UdpSupport {
Ok(Ok(verdict)) => verdict,
Ok(Err(e)) => {
log::debug!(
"UDP probe of {}:{} could not complete: {e}",
"UDP probe inconclusive host={} port={} err=\"{e}\"",
settings.host,
settings.port
);
UdpSupport::Unknown
}
Err(_) => {
log::debug!("UDP probe of {}:{} timed out", settings.host, settings.port);
log::debug!(
"UDP probe timed out host={} port={}",
settings.host,
settings.port
);
UdpSupport::Unknown
}
}
+5 -1
View File
@@ -159,7 +159,11 @@ fn validate_recipe(name: &str, steps: &[RecipeStep]) -> Result<(String, Vec<Reci
}
fn failure(action: &str, err: BackendFailure) -> String {
log::warn!("Recipe {action} failed: {} (HTTP {})", err.code, err.status);
log::warn!(
"Recipe request failed action={action} code={} status={}",
err.code,
err.status
);
err.to_error_json()
}
+33 -10
View File
@@ -138,8 +138,8 @@ fn load_from_disk() -> Store {
// Losing the file means losing the gate, so say so loudly rather than
// starting empty and quietly permitting a launch over pending work.
log::error!(
"Could not read {}: {e}. Profiles with unsynced remote work will not be gated until \
the next session event.",
"Remote handoff store unreadable; unsynced remote work is not gated until the next \
session event path=\"{}\" err=\"{e}\"",
path.display()
);
Store::new()
@@ -151,17 +151,23 @@ fn persist(store: &Store) {
let path = store_path();
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Could not create {}: {e}", parent.display());
log::warn!(
"Remote handoff store dir create failed path=\"{}\" err=\"{e}\"",
parent.display()
);
return;
}
}
match serde_json::to_vec_pretty(store) {
Ok(bytes) => {
if let Err(e) = crate::app_dirs::write_owner_only(&path, &bytes) {
log::warn!("Could not write {}: {e}", path.display());
log::warn!(
"Remote handoff store write failed path=\"{}\" err=\"{e}\"",
path.display()
);
}
}
Err(e) => log::warn!("Could not encode the remote handoff store: {e}"),
Err(e) => log::warn!("Remote handoff store encode failed err=\"{e}\""),
}
}
@@ -355,8 +361,7 @@ pub fn reconcile(live_session_ids: &std::collections::HashSet<String>) -> Vec<St
.collect();
for (profile_id, session_id) in stale {
log::info!(
"Remote session {} for profile {profile_id} ended while this machine was not \
watching; its work is still in cloud storage",
"Remote session ended unobserved; work awaits pull profile={profile_id} session={}",
ShortId(&session_id)
);
store.insert(
@@ -446,17 +451,25 @@ where
E: Fn() -> bool,
{
let mut attempt = 0u32;
let mut tries = 0u32;
let mut failures = 0u32;
while let Some(session_id) = worker.pending_session() {
let profile_id = &worker.profile_id;
tries = tries.saturating_add(1);
match pull().await {
Ok(outcome) if outcome.is_completed() => {
log::info!("Pulled remote session work for profile {profile_id}");
log::info!(
"Remote session work pulled profile={profile_id} session={} tries={tries} failures={failures}",
ShortId(&session_id)
);
clear_pending(profile_id, &session_id);
attempt = 0;
tries = 0;
failures = 0;
continue;
}
Ok(crate::sync::ProfileSyncOutcome::Skipped(reason)) => {
log::debug!("Post-session pull for profile {profile_id} is waiting: {reason}");
log::debug!("Post-session pull waiting profile={profile_id} reason=\"{reason}\"");
}
Ok(_) => unreachable!("is_completed covers every completed outcome"),
Err(e) => {
@@ -464,7 +477,17 @@ where
clear_pending(profile_id, &session_id);
continue;
}
log::warn!("Post-session pull for profile {profile_id} failed: {e}");
failures = failures.saturating_add(1);
// Retried with backoff; only the first failure of a run is a warning.
let level = if failures == 1 {
log::Level::Warn
} else {
log::Level::Debug
};
log::log!(
level,
"Post-session pull failed; retrying profile={profile_id} failures={failures} err=\"{e}\""
);
}
}
if worker.pending_session().is_none() {
+20 -13
View File
@@ -207,7 +207,7 @@ fn local_exit_refusal(verdict: &ExitReachability) -> Option<RemoteSessionError>
// sidecar that is not available remotely, and no retry changes that.
ExitReachability::UnsupportedKind { kind, .. } => {
log::warn!(
"Refusing an interactive remote session: {}",
"Remote session refused reason=\"{}\"",
verdict
.refusal_detail()
.unwrap_or_else(|| format!("the profile's exit is {kind}"))
@@ -228,7 +228,7 @@ fn local_exit_refusal(verdict: &ExitReachability) -> Option<RemoteSessionError>
// The prose names the offending host, which belongs in the log where
// support can read it. The toast gets the code so it stays translated.
if let Some(detail) = unusable.refusal_detail() {
log::warn!("Refusing an interactive remote session: {detail}");
log::warn!("Remote session refused reason=\"{detail}\"");
}
// `Other` rather than a typed variant: the other three are each pinned to
// a status and a meaning — "the fleet is busy", "already open somewhere",
@@ -763,7 +763,7 @@ pub async fn live_session_for_profile(profile_id: &str) -> Option<RemoteSessionS
with_index(|map| map.get(profile_id).cloned())
}
Err(e) => {
log::debug!("Could not refresh remote sessions while resolving a CDP target: {e}");
log::debug!("Remote session refresh for CDP target failed err=\"{e}\"");
None
}
}
@@ -915,7 +915,7 @@ pub fn route_frame(event: Option<&str>, data: &str) -> Option<(&'static str, ser
let payload = match serde_json::from_str::<serde_json::Value>(data) {
Ok(value) => value,
Err(e) => {
log::warn!("Ignoring malformed remote-session event: {e}");
log::warn!("Remote session event malformed, ignored err=\"{e}\"");
return None;
}
};
@@ -951,7 +951,7 @@ pub fn route_frame(event: Option<&str>, data: &str) -> Option<(&'static str, ser
if object.contains_key("session_id") {
return Some((EVENT_SESSION_STATE, payload));
}
log::warn!("Ignoring remote-session frame with no session: {kind}");
log::warn!("Remote session frame without a session, ignored kind={kind}");
None
}
@@ -963,6 +963,11 @@ pub fn reconnect_delay(attempt: u32) -> Duration {
.min(RECONNECT_MAX)
}
static STREAM_CONNECT: crate::log_streak::Streak =
crate::log_streak::Streak::new(module_path!(), "Remote session stream connect");
static STREAM_DROP: crate::log_streak::Streak =
crate::log_streak::Streak::new(module_path!(), "Remote session stream");
/// Start or wake the one subscriber that receives session transitions.
pub fn start_session_events(app: AppHandle) {
crate::remote_handoff::resume_pending_pulls(&app);
@@ -1013,14 +1018,16 @@ async fn run_session_events(app: AppHandle) {
match connect_session_events(last_event_id.as_deref()).await {
Ok(response) => {
attempt = 0;
STREAM_CONNECT.succeeded();
emit_stream_status(&app, true, None);
match consume_session_events(&app, response, &mut last_event_id).await {
Ok(()) => {
log::info!("Remote session stream closed by the backend");
STREAM_DROP.succeeded();
log::debug!("Remote session stream closed by the backend");
emit_stream_status(&app, false, None);
}
Err(reason) => {
log::warn!("Remote session stream ended: {reason}");
STREAM_DROP.failed(&reason);
emit_stream_status(&app, false, Some(&reason));
}
}
@@ -1031,7 +1038,7 @@ async fn run_session_events(app: AppHandle) {
auth_failure = true;
attempt = attempt.max(AUTH_BACKOFF_ATTEMPT);
}
log::warn!("Remote session stream could not connect: {reason}");
STREAM_CONNECT.failed(&reason);
emit_stream_status(&app, false, Some(&reason));
}
}
@@ -1186,7 +1193,7 @@ fn dispatch_frame(app: &AppHandle, frame: &SseFrame) {
use tauri::Emitter;
if let Err(e) = app.emit(target, payload) {
log::warn!("Failed to emit {target}: {e}");
log::warn!("Event not emitted event={target} err=\"{e}\"");
}
}
@@ -1200,14 +1207,14 @@ pub fn apply_to_index(app: Option<&AppHandle>, target: &str, payload: &serde_jso
let Some(array) = payload.get("sessions").and_then(|v| v.as_array()) else {
// Marking the index authoritative off a frame that carried no list would
// answer "no session" for every profile until the next reconnect.
log::warn!("Ignoring a remote-session snapshot that carried no session list");
log::warn!("Remote session snapshot without a session list, ignored");
return;
};
let mut sessions = Vec::with_capacity(array.len());
for value in array {
match serde_json::from_value::<RemoteSessionState>(value.clone()) {
Ok(session) => sessions.push(session),
Err(e) => log::warn!("Skipping an undecodable session in the snapshot: {e}"),
Err(e) => log::warn!("Remote session in snapshot undecodable, skipped err=\"{e}\""),
}
}
reindex(app, &sessions);
@@ -1218,7 +1225,7 @@ pub fn apply_to_index(app: Option<&AppHandle>, target: &str, payload: &serde_jso
if target == EVENT_SESSION_STATE {
match serde_json::from_value::<RemoteSessionState>(payload.clone()) {
Ok(session) => index_session(app, &session),
Err(e) => log::warn!("Ignoring an undecodable session transition: {e}"),
Err(e) => log::warn!("Remote session transition undecodable, ignored err=\"{e}\""),
}
}
}
@@ -1234,7 +1241,7 @@ fn emit_stream_status(app: &AppHandle, connected: bool, reason: Option<&str>) {
use tauri::Emitter;
let payload = serde_json::json!({ "connected": connected, "reason": reason });
if let Err(e) = app.emit(EVENT_STREAM_STATUS, payload) {
log::warn!("Failed to emit {EVENT_STREAM_STATUS}: {e}");
log::warn!("Event not emitted event={EVENT_STREAM_STATUS} err=\"{e}\"");
}
}
+20 -8
View File
@@ -106,7 +106,7 @@ pub fn get_table_preferences(table_id: String) -> Result<TablePreferences, Strin
Ok(preferences)
};
load().map_err(|error| {
log::warn!("Cannot load table preferences: {error}");
log::warn!("Table preferences load failed table={table_id} err=\"{error}\"");
serde_json::json!({"code": "TABLE_PREFERENCES_LOAD_FAILED"}).to_string()
})
}
@@ -131,7 +131,7 @@ pub fn save_table_preferences(
Ok(())
};
persist().map_err(|error| {
log::warn!("Cannot save table preferences: {error}");
log::warn!("Table preferences save failed table={table_id} err=\"{error}\"");
serde_json::json!({"code": "TABLE_PREFERENCES_SAVE_FAILED"}).to_string()
})
}
@@ -372,7 +372,7 @@ impl SettingsManager {
match serde_json::from_str::<AppSettings>(&content) {
Ok(settings) => Ok(settings),
Err(e) => {
log::warn!("Warning: Failed to parse settings file, using defaults: {e}");
log::warn!("Settings file unparsable; using defaults err=\"{e}\"");
Ok(AppSettings::default())
}
}
@@ -701,8 +701,8 @@ pub async fn save_app_settings(
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
log::info!(
"[settings] Saving settings: theme={}, custom_theme_keys={}",
log::debug!(
"Settings saved theme={} custom_theme_keys={}",
persist_settings.theme,
persist_settings
.custom_theme
@@ -818,24 +818,36 @@ pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), Stri
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
let child = std::process::Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
log::info!(
"Process spawned pid={} purpose=\"reveal log dir\" tool=open",
child.id()
);
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
let child = std::process::Command::new("explorer")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
log::info!(
"Process spawned pid={} purpose=\"reveal log dir\" tool=explorer",
child.id()
);
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
let child = std::process::Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
log::info!(
"Process spawned pid={} purpose=\"reveal log dir\" tool=xdg-open",
child.id()
);
}
Ok(())
}
+47 -37
View File
@@ -213,14 +213,14 @@ pub async fn handle_socks5_connection(
let _ = stream.set_nodelay(true);
if let Err(e) = negotiate_method(&mut stream).await {
log::debug!("SOCKS5 method negotiation failed: {e}");
log::debug!("SOCKS5 method negotiation failed err=\"{e}\"");
return;
}
let request = match read_request(&mut stream).await {
Ok(r) => r,
Err(e) => {
log::debug!("SOCKS5 request parse failed: {e}");
log::debug!("SOCKS5 request parse failed err=\"{e}\"");
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
return;
}
@@ -242,7 +242,7 @@ pub async fn handle_socks5_connection(
handle_udp_associate(stream, upstream_url, blocklist_matcher).await;
}
other => {
log::debug!("SOCKS5 unsupported command {other:#04x}");
log::debug!("SOCKS5 unsupported command cmd={other:#04x}");
let _ = send_reply(&mut stream, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
}
}
@@ -304,6 +304,21 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result<Socks5Request>
})
}
static UDP_REFUSED_LOGGED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static UDP_UPSTREAM_REFUSED_LOGGED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Info the first time per worker, debug after: the browser asks again for
/// every new UDP flow, and the answer does not change.
fn first_time(flag: &std::sync::atomic::AtomicBool) -> log::Level {
if flag.swap(true, std::sync::atomic::Ordering::Relaxed) {
log::Level::Debug
} else {
log::Level::Info
}
}
/// The upstream as a log line may show it: the URL carries the proxy's
/// username and password, and the worker log is a plain file in the temp dir.
fn upstream_label(upstream_url: Option<&str>) -> String {
@@ -371,7 +386,7 @@ async fn handle_connect(
blocklist_matcher: BlocklistMatcher,
) {
if blocklist_matcher.is_blocked(&host) {
log::debug!("[blocklist] Blocked SOCKS5 CONNECT to {host}");
log::debug!("Blocklist blocked SOCKS5 CONNECT host={host}");
let _ = send_reply(&mut stream, REP_NOT_ALLOWED, unspecified()).await;
return;
}
@@ -389,29 +404,20 @@ async fn handle_connect(
// Resolve to the target stream, logging and dropping the (non-Send) dial
// error inside the match arm so it is never held across the await below.
let target = match connect_to_target_via_upstream(
&host,
port,
upstream_url.as_deref(),
&bypass_matcher,
)
.await
{
Ok(t) => Some(t),
Err(e) => {
let key = format!("socks5-connect:{host}:{port}");
if let Some(suppressed) = crate::proxy_server::log_throttle(&key) {
if suppressed > 0 {
log::warn!(
"SOCKS5 CONNECT to {host}:{port} failed: {e} ({suppressed} more suppressed in last 30s)"
);
} else {
log::warn!("SOCKS5 CONNECT to {host}:{port} failed: {e}");
}
let target =
match connect_to_target_via_upstream(&host, port, upstream_url.as_deref(), &bypass_matcher)
.await
{
Ok(t) => Some(t),
Err(e) => {
crate::proxy_server::warn_upstream_failure(
"SOCKS5 CONNECT failed",
&e.to_string(),
Some(&host),
);
None
}
None
}
};
};
let Some(target) = target else {
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
@@ -441,8 +447,9 @@ async fn handle_udp_associate(
let mode = udp_mode(upstream_url.as_deref());
if mode == UdpMode::Refuse {
log::info!(
"SOCKS5 UDP ASSOCIATE refused: upstream ({}) cannot carry UDP without leaking; Chromium will use proxied TCP",
log::log!(
first_time(&UDP_REFUSED_LOGGED),
"SOCKS5 UDP ASSOCIATE refused: upstream cannot carry UDP without leaking; browser uses proxied TCP upstream={}",
upstream_label(upstream_url.as_deref())
);
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
@@ -453,7 +460,7 @@ async fn handle_udp_associate(
let relay = match UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await {
Ok(s) => s,
Err(e) => {
log::warn!("Failed to bind UDP relay socket: {e}");
crate::proxy_server::warn_throttled(&format!("UDP relay socket bind failed err=\"{e}\""));
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
return;
}
@@ -461,7 +468,7 @@ async fn handle_udp_associate(
let relay_addr = match relay.local_addr() {
Ok(a) => a,
Err(e) => {
log::warn!("Failed to read UDP relay addr: {e}");
crate::proxy_server::warn_throttled(&format!("UDP relay address read failed err=\"{e}\""));
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
return;
}
@@ -474,7 +481,9 @@ async fn handle_udp_associate(
let out = match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await {
Ok(s) => s,
Err(e) => {
log::warn!("Failed to bind UDP egress socket: {e}");
crate::proxy_server::warn_throttled(&format!(
"UDP egress socket bind failed err=\"{e}\""
));
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
return;
}
@@ -485,7 +494,7 @@ async fn handle_udp_associate(
{
return;
}
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
log::debug!("SOCKS5 UDP ASSOCIATE relaying mode=direct relay={relay_addr}");
run_udp_relay_direct(control, relay, out, UdpRelayContext::new(blocklist_matcher)).await;
}
UdpMode::Socks5Upstream => {
@@ -495,8 +504,9 @@ async fn handle_udp_associate(
let datagram = match associate_upstream(upstream).await {
Ok(d) => d,
Err(e) => {
log::info!(
"SOCKS5 upstream did not grant UDP ASSOCIATE ({e}); refusing so Chromium uses proxied TCP"
log::log!(
first_time(&UDP_UPSTREAM_REFUSED_LOGGED),
"SOCKS5 UDP ASSOCIATE refused: upstream did not grant UDP; browser uses proxied TCP err=\"{e}\""
);
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
return;
@@ -508,7 +518,7 @@ async fn handle_udp_associate(
{
return;
}
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
log::debug!("SOCKS5 UDP ASSOCIATE relaying mode=socks5_upstream relay={relay_addr}");
run_udp_relay_socks5(
control,
relay,
@@ -655,7 +665,7 @@ async fn run_udp_relay_direct(
}
let host = addrkind_host(&header.dst);
if ctx.is_blocked(&host) {
log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}");
log::debug!("Blocklist blocked SOCKS5 UDP datagram host={host}");
continue;
}
let payload = &from_client[header.data_offset..n];
@@ -716,7 +726,7 @@ async fn run_udp_relay_socks5(
}
let host = addrkind_host(&header.dst);
if ctx.is_blocked(&host) {
log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}");
log::debug!("Blocklist blocked SOCKS5 UDP datagram host={host}");
continue;
}
let peer_key = addrkind_key(&header.dst);
File diff suppressed because it is too large. Load diff
+2 -5
View File
@@ -274,10 +274,7 @@ pub fn generate_manifest(
let mut max_mtime: i64 = 0;
if !profile_dir.exists() {
log::debug!(
"Profile directory doesn't exist: {}, creating empty manifest",
profile_dir.display()
);
log::debug!("Profile dir missing, empty manifest profile={profile_id}");
return Ok(manifest);
}
@@ -470,7 +467,7 @@ pub fn compute_diff_with_bias(
// would appear "newer" and cause all remote files to be deleted.
if local.files.is_empty() && !remote.files.is_empty() {
log::info!(
"Local manifest is empty but remote has {} files — downloading from remote to recover",
"Local manifest empty, recovering from remote files={}",
remote.files.len()
);
diff.files_to_download = remote.files.clone();
+6 -6
View File
@@ -83,7 +83,7 @@ pub async fn resume_subscription(app_handle: tauri::AppHandle) {
if !subscription_manager.is_running() {
if let Err(e) = subscription_manager.start(app_handle).await {
log::warn!("Failed to resume sync subscription: {e}");
log::warn!("Sync subscription resume failed err=\"{e}\"");
}
}
@@ -116,7 +116,7 @@ pub async fn start_pipeline(app_handle: tauri::AppHandle) {
let mut subscription_manager = SubscriptionManager::new();
let Some(work_rx) = subscription_manager.take_work_receiver() else {
log::error!("Sync pipeline has no work receiver; not starting");
log::error!("Sync pipeline not started: no work receiver");
return;
};
@@ -125,7 +125,7 @@ pub async fn start_pipeline(app_handle: tauri::AppHandle) {
// restart path used to give up here, which turned a token hiccup into sync
// being dead until the next launch.
if let Err(e) = subscription_manager.start(app_handle.clone()).await {
log::warn!("Failed to start sync subscription, continuing without live updates: {e}");
log::warn!("Sync subscription not started, no live updates err=\"{e}\"");
}
if let Ok(mut guard) = GLOBAL_SUBSCRIPTION.lock() {
*guard = Some(subscription_manager);
@@ -143,14 +143,14 @@ pub async fn start_pipeline(app_handle: tauri::AppHandle) {
match SyncEngine::create_from_settings(&app_handle).await {
Ok(engine) => {
if let Err(e) = engine.check_for_missing_synced_profiles(&app_handle).await {
log::warn!("Failed to check for missing profiles: {e}");
log::warn!("Missing profile check failed err=\"{e}\"");
}
if let Err(e) = engine.check_for_missing_synced_entities(&app_handle).await {
log::warn!("Failed to check for missing entities: {e}");
log::warn!("Missing entity check failed err=\"{e}\"");
}
}
Err(e) => {
log::warn!("Sync not configured, skipping missing profile check: {e}");
log::debug!("Missing profile check skipped err=\"{e}\"");
}
}
+4 -4
View File
@@ -44,7 +44,7 @@ fn read() -> Vec<PendingDelete> {
return Vec::new();
};
serde_json::from_str(&content).unwrap_or_else(|e| {
log::warn!("Ignoring unreadable {FILE_NAME}: {e}");
log::warn!("Pending cloud deletes unreadable, ignored file={FILE_NAME} err=\"{e}\"");
Vec::new()
})
}
@@ -54,7 +54,7 @@ fn write(entries: &[PendingDelete]) {
if entries.is_empty() {
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
log::warn!("Could not remove {}: {e}", path.display());
log::warn!("Pending cloud deletes not removed file={FILE_NAME} err=\"{e}\"");
}
}
return;
@@ -70,7 +70,7 @@ fn write(entries: &[PendingDelete]) {
std::fs::rename(&tmp, &path)
});
if let Err(e) = result {
log::warn!("Could not write {}: {e}", path.display());
log::warn!("Pending cloud deletes not written file={FILE_NAME} err=\"{e}\"");
}
}
@@ -114,7 +114,7 @@ pub async fn wait_for(profile_id: &str) {
let handle = lock(&RUNNING).remove(profile_id);
if let Some(handle) = handle {
if let Err(e) = handle.await {
log::warn!("The cloud delete of profile {profile_id} ended abnormally: {e}");
log::warn!("Cloud profile delete task ended abnormally profile={profile_id} err=\"{e}\"");
}
}
}
+2 -2
View File
@@ -88,7 +88,7 @@ pub async fn check_sync_server(server_url: &str) -> SyncServerCheck {
let readyz = match client.get(format!("{base}/readyz")).send().await {
Ok(response) => response,
Err(e) => {
log::warn!("Sync pre-flight: {base}/readyz did not answer: {e}");
log::warn!("Sync preflight: readyz unreachable err=\"{e}\"");
return check;
}
};
@@ -119,7 +119,7 @@ pub async fn check_sync_server(server_url: &str) -> SyncServerCheck {
match probe_storage_endpoint(&client, &endpoint).await {
Ok(()) => check.storage_reachable = Some(true),
Err(e) => {
log::warn!("Sync pre-flight: storage endpoint {endpoint} is unreachable from here: {e}");
log::warn!("Sync preflight: storage endpoint unreachable endpoint={endpoint} err=\"{e}\"");
check.storage_reachable = Some(false);
check.storage_error = Some(e);
}
+143 -106
View File
@@ -2,16 +2,60 @@ use super::engine::SyncEngine;
use super::subscription::SyncWorkItem;
use crate::events;
use crate::log_redaction::Plain;
use crate::log_streak::Streak;
use crate::profile::ProfileManager;
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio::sync::Mutex;
static GLOBAL_SCHEDULER: std::sync::Mutex<Option<Arc<SyncScheduler>>> = std::sync::Mutex::new(None);
static SYNC_ENGINE: Streak = Streak::new(module_path!(), "Sync engine setup");
/// The last error per `kind=id`, so an entity that keeps failing the same way
/// logs once, not on every pass.
static FAILING: LazyLock<std::sync::Mutex<HashMap<String, String>>> =
LazyLock::new(Default::default);
/// Tombstones already reported this run. The server replays every tombstone
/// to each new SSE connection.
static NOTED_TOMBSTONES: LazyLock<std::sync::Mutex<HashSet<String>>> =
LazyLock::new(Default::default);
fn log_sync_failure(kind: &str, id: &str, err: impl Display) {
let err = err.to_string();
let repeated = FAILING
.lock()
.map(|mut failing| failing.insert(format!("{kind}={id}"), err.clone()).as_ref() == Some(&err))
.unwrap_or(false);
if repeated {
log::debug!("Sync failed again {kind}={id} err=\"{err}\"");
} else {
log::error!("Sync failed {kind}={id} err=\"{err}\"");
}
}
fn log_sync_success(kind: &str, id: &str) {
let recovered = FAILING
.lock()
.map(|mut failing| failing.remove(&format!("{kind}={id}")).is_some())
.unwrap_or(false);
if recovered {
log::info!("Sync recovered {kind}={id}");
}
}
fn first_tombstone_notice(entity_id: &str) -> bool {
NOTED_TOMBSTONES
.lock()
.map(|mut noted| noted.insert(entity_id.to_string()))
.unwrap_or(true)
}
pub fn get_global_scheduler() -> Option<Arc<SyncScheduler>> {
GLOBAL_SCHEDULER.lock().ok().and_then(|g| g.clone())
}
@@ -171,13 +215,13 @@ impl SyncScheduler {
pub async fn mark_profile_running(&self, profile_id: &str) {
let mut running = self.running_profiles.lock().await;
running.insert(profile_id.to_string());
log::debug!("Marked profile {} as running", profile_id);
log::debug!("Profile marked running profile={profile_id}");
}
pub async fn mark_profile_stopped(&self, profile_id: &str) {
let mut running = self.running_profiles.lock().await;
running.remove(profile_id);
log::debug!("Marked profile {} as stopped", profile_id);
log::debug!("Profile marked stopped profile={profile_id}");
let mut pending = self.pending_profiles.lock().await;
if pending.contains_key(profile_id) {
@@ -189,10 +233,7 @@ impl SyncScheduler {
queued: true,
},
);
log::debug!(
"Profile {} has pending sync, will execute immediately",
profile_id
);
log::debug!("Profile pending sync released profile={profile_id}");
}
}
@@ -209,10 +250,7 @@ impl SyncScheduler {
.is_locked_by_another(profile_id)
.await
{
log::debug!(
"Profile {} is locked on another device, treating as running",
profile_id
);
log::debug!("Profile locked on another device, treated as running profile={profile_id}");
return true;
}
@@ -241,7 +279,7 @@ impl SyncScheduler {
},
);
log::debug!(
"Profile {} is running, queued sync for after stop",
"Profile sync queued until stop profile={}",
Plain(&profile_id)
);
} else {
@@ -253,7 +291,7 @@ impl SyncScheduler {
queued: true,
},
);
log::debug!("Profile {} queued for immediate sync", Plain(&profile_id));
log::debug!("Profile sync queued profile={}", Plain(&profile_id));
}
}
@@ -293,14 +331,12 @@ impl SyncScheduler {
}
pub async fn sync_all_enabled_profiles(&self, _app_handle: &tauri::AppHandle) {
log::info!("Starting initial sync for all enabled profiles...");
let profiles = {
let profile_manager = ProfileManager::instance();
match profile_manager.list_profiles() {
Ok(p) => p,
Err(e) => {
log::error!("Failed to list profiles for initial sync: {e}");
log::error!("Initial sync not queued: profile list failed err=\"{e}\"");
return;
}
}
@@ -312,12 +348,12 @@ impl SyncScheduler {
.collect();
if sync_enabled_profiles.is_empty() {
log::debug!("No sync-enabled profiles found");
log::debug!("Initial sync skipped: no sync-enabled profiles");
return;
}
log::info!(
"Found {} sync-enabled profiles, queueing for sync",
"Initial sync queued profiles={}",
sync_enabled_profiles.len()
);
@@ -335,14 +371,9 @@ impl SyncScheduler {
}
if should_wait {
log::info!(
"Profile '{}' is {} — will sync after it becomes available",
profile.name,
if is_running {
"running locally"
} else {
"locked by a team member"
}
log::debug!(
"Initial sync deferred profile={profile_id} reason={}",
if is_running { "running" } else { "team_locked" }
);
}
@@ -388,11 +419,11 @@ impl SyncScheduler {
// Retired while the pipeline was still assembling it. Starting now
// would leave a task nothing can stop, because the handle in the global
// has already been replaced.
log::info!("Sync scheduler was retired before it started; not starting it");
log::info!("Sync scheduler not started: retired before start");
return false;
}
StartDecision::AlreadyRunning => {
log::warn!("Sync scheduler is already running; ignoring the second start");
log::warn!("Sync scheduler already running, second start ignored");
return false;
}
StartDecision::Start => {}
@@ -445,9 +476,7 @@ impl SyncScheduler {
// The subscription is gone, so no more live updates from other
// devices. Local changes and the timer still work, so keep
// ticking rather than ending the scheduler.
log::warn!(
"Sync work channel closed; continuing on the timer without live updates"
);
log::warn!("Sync work channel closed, timer only from now");
work_channel_open = false;
}
}
@@ -503,7 +532,7 @@ impl SyncScheduler {
for profile_id in profiles_to_sync {
let mut in_flight = self.in_flight_profiles.lock().await;
if in_flight.contains(&profile_id) {
log::debug!("Profile {} already in-flight, skipping", profile_id);
log::debug!("Profile sync already in flight profile={profile_id}");
continue;
}
in_flight.insert(profile_id.clone());
@@ -516,7 +545,8 @@ impl SyncScheduler {
let app = app_handle.clone();
let in_flight = self.in_flight_profiles.clone();
sync_set.spawn(async move {
log::info!("Executing queued sync for profile {}", profile_id);
let started = Instant::now();
log::debug!("Profile sync started profile={profile_id}");
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
@@ -542,12 +572,13 @@ impl SyncScheduler {
let result = match SyncEngine::create_from_settings(&app).await {
Ok(engine) => {
SYNC_ENGINE.succeeded();
engine
.sync_profile_with_bias(&app, &profile, super::DiffBias::Auto)
.await
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
SYNC_ENGINE.failed(e);
Err(super::types::SyncError::NotConfigured)
}
};
@@ -559,7 +590,11 @@ impl SyncScheduler {
match result {
Ok(super::ProfileSyncOutcome::Completed) => {
log::info!("Profile {} synced successfully", profile_id);
log_sync_success("profile", &profile_id);
log::debug!(
"Profile sync completed profile={profile_id} elapsed_ms={}",
started.elapsed().as_millis()
);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
@@ -569,10 +604,18 @@ impl SyncScheduler {
);
}
Ok(super::ProfileSyncOutcome::Skipped(reason)) => {
log::debug!("Profile {profile_id} sync is waiting: {reason}");
log::debug!("Profile sync skipped profile={profile_id} reason=\"{reason}\"");
}
Err(e) => {
log::error!("Failed to sync profile {}: {}", profile_id, e);
// Both are logged where they happen.
if matches!(
e,
super::types::SyncError::Cancelled | super::types::SyncError::NotConfigured
) {
log::debug!("Profile sync ended profile={profile_id} err=\"{e}\"");
} else {
log_sync_failure("profile", &profile_id, &e);
}
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
@@ -590,7 +633,7 @@ impl SyncScheduler {
if !sync_set.is_empty() {
while let Some(result) = sync_set.join_next().await {
if let Err(e) = result {
log::error!("Profile sync task panicked: {e}");
log::error!("Profile sync task panicked err=\"{e}\"");
}
}
}
@@ -609,8 +652,9 @@ impl SyncScheduler {
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
SYNC_ENGINE.succeeded();
for proxy_id in proxies_to_sync {
log::info!("Syncing proxy {}", proxy_id);
log::debug!("Sync started proxy={proxy_id}");
let _ = events::emit(
"proxy-sync-status",
serde_json::json!({
@@ -623,6 +667,7 @@ impl SyncScheduler {
.await
{
Ok(()) => {
log_sync_success("proxy", &proxy_id);
let _ = events::emit(
"proxy-sync-status",
serde_json::json!({
@@ -632,7 +677,7 @@ impl SyncScheduler {
);
}
Err(e) => {
log::error!("Failed to sync proxy {}: {}", proxy_id, e);
log_sync_failure("proxy", &proxy_id, &e);
let _ = events::emit(
"proxy-sync-status",
serde_json::json!({
@@ -647,9 +692,7 @@ impl SyncScheduler {
// Check if all sync work is complete after proxies finish
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
Err(e) => SYNC_ENGINE.failed(e),
}
}
@@ -666,8 +709,9 @@ impl SyncScheduler {
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
SYNC_ENGINE.succeeded();
for group_id in groups_to_sync {
log::info!("Syncing group {}", group_id);
log::debug!("Sync started group={group_id}");
let _ = events::emit(
"group-sync-status",
serde_json::json!({
@@ -680,6 +724,7 @@ impl SyncScheduler {
.await
{
Ok(()) => {
log_sync_success("group", &group_id);
let _ = events::emit(
"group-sync-status",
serde_json::json!({
@@ -689,7 +734,7 @@ impl SyncScheduler {
);
}
Err(e) => {
log::error!("Failed to sync group {}: {}", group_id, e);
log_sync_failure("group", &group_id, &e);
let _ = events::emit(
"group-sync-status",
serde_json::json!({
@@ -704,9 +749,7 @@ impl SyncScheduler {
// Check if all sync work is complete after groups finish
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
Err(e) => SYNC_ENGINE.failed(e),
}
}
@@ -723,8 +766,9 @@ impl SyncScheduler {
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
SYNC_ENGINE.succeeded();
for vpn_id in vpns_to_sync {
log::info!("Syncing VPN {}", vpn_id);
log::debug!("Sync started vpn={vpn_id}");
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
@@ -734,6 +778,7 @@ impl SyncScheduler {
);
match engine.sync_vpn_by_id_with_handle(&vpn_id, app_handle).await {
Ok(()) => {
log_sync_success("vpn", &vpn_id);
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
@@ -743,7 +788,7 @@ impl SyncScheduler {
);
}
Err(e) => {
log::error!("Failed to sync VPN {}: {}", vpn_id, e);
log_sync_failure("vpn", &vpn_id, &e);
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
@@ -756,9 +801,7 @@ impl SyncScheduler {
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
Err(e) => SYNC_ENGINE.failed(e),
}
}
@@ -775,8 +818,9 @@ impl SyncScheduler {
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
SYNC_ENGINE.succeeded();
for ext_id in extensions_to_sync {
log::info!("Syncing extension {}", ext_id);
log::debug!("Sync started extension={ext_id}");
let _ = events::emit(
"extension-sync-status",
serde_json::json!({ "id": ext_id, "status": "syncing" }),
@@ -785,12 +829,13 @@ impl SyncScheduler {
.sync_extension_by_id_with_handle(&ext_id, app_handle)
.await
{
log::error!("Failed to sync extension {}: {}", ext_id, e);
log_sync_failure("extension", &ext_id, &e);
let _ = events::emit(
"extension-sync-status",
serde_json::json!({ "id": ext_id, "status": "error" }),
);
} else {
log_sync_success("extension", &ext_id);
let _ = events::emit(
"extension-sync-status",
serde_json::json!({ "id": ext_id, "status": "synced" }),
@@ -798,9 +843,7 @@ impl SyncScheduler {
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
Err(e) => SYNC_ENGINE.failed(e),
}
}
@@ -817,8 +860,9 @@ impl SyncScheduler {
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
SYNC_ENGINE.succeeded();
for group_id in groups_to_sync {
log::info!("Syncing extension group {}", group_id);
log::debug!("Sync started extension_group={group_id}");
let _ = events::emit(
"extension-sync-status",
serde_json::json!({ "id": group_id, "status": "syncing" }),
@@ -827,12 +871,13 @@ impl SyncScheduler {
.sync_extension_group_by_id_with_handle(&group_id, app_handle)
.await
{
log::error!("Failed to sync extension group {}: {}", group_id, e);
log_sync_failure("extension_group", &group_id, &e);
let _ = events::emit(
"extension-sync-status",
serde_json::json!({ "id": group_id, "status": "error" }),
);
} else {
log_sync_success("extension_group", &group_id);
let _ = events::emit(
"extension-sync-status",
serde_json::json!({ "id": group_id, "status": "synced" }),
@@ -840,9 +885,7 @@ impl SyncScheduler {
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
Err(e) => SYNC_ENGINE.failed(e),
}
}
@@ -884,27 +927,30 @@ impl SyncScheduler {
}
for (entity_type, entity_id) in tombstones {
log::info!("Processing tombstone for {} {}", entity_type, entity_id);
log::debug!("Tombstone received {entity_type}={entity_id}");
match entity_type.as_str() {
"profile" => {
let profile_manager = ProfileManager::instance();
let local = uuid::Uuid::parse_str(&entity_id)
.ok()
.and_then(|uuid| {
profile_manager
.list_profiles()
.ok()?
.into_iter()
.find(|p| p.id == uuid)
})
.filter(|p| p.is_sync_enabled());
let found = uuid::Uuid::parse_str(&entity_id).ok().and_then(|uuid| {
profile_manager
.list_profiles()
.ok()?
.into_iter()
.find(|p| p.id == uuid)
});
let Some(local) = local else {
log::info!(
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy",
entity_id
);
continue;
let local = match found {
Some(profile) if profile.is_sync_enabled() => profile,
Some(_) => {
if first_tombstone_notice(&entity_id) {
log::info!("Tombstone ignored, sync off locally profile={entity_id}");
}
continue;
}
None => {
log::debug!("Tombstone ignored, profile not on this device profile={entity_id}");
continue;
}
};
// The event can be stale. A restore from the trash, or sync switched
@@ -912,30 +958,25 @@ impl SyncScheduler {
// the profile it named is live again. Only a tombstone that is still
// in the cloud erases the local copy.
let still_tombstoned = match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => engine.profile_tombstone_exists(&local).await,
Ok(engine) => {
SYNC_ENGINE.succeeded();
engine.profile_tombstone_exists(&local).await
}
Err(e) => {
log::warn!(
"Could not confirm the tombstone of profile {}: {}",
entity_id,
e
);
false
SYNC_ENGINE.failed(e);
continue;
}
};
if !still_tombstoned {
log::info!(
"Profile {} tombstone is no longer in the cloud — keeping local copy",
entity_id
);
log::info!("Tombstone stale, kept local profile profile={entity_id}");
continue;
}
log::info!(
"Profile {} was deleted remotely, deleting locally",
entity_id
);
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
match profile_manager.delete_profile_local_only(&entity_id) {
Ok(_) => log::info!("Profile deleted remotely, removed locally profile={entity_id}"),
Err(e) => log::warn!(
"Remotely deleted profile not removed locally profile={entity_id} err=\"{e}\""
),
}
}
"proxy" => {
@@ -943,7 +984,7 @@ impl SyncScheduler {
let proxies = proxy_manager.get_stored_proxies();
if let Some(proxy) = proxies.iter().find(|p| p.id == entity_id) {
if proxy.sync_enabled {
log::info!("Proxy {} was deleted remotely, deleting locally", entity_id);
log::info!("Proxy deleted remotely, removed locally proxy={entity_id}");
let proxy_file = proxy_manager.get_proxy_file_path(&entity_id);
if proxy_file.exists() {
let _ = std::fs::remove_file(&proxy_file);
@@ -958,7 +999,7 @@ impl SyncScheduler {
let groups = group_manager.get_all_groups().unwrap_or_default();
if let Some(group) = groups.iter().find(|g| g.id == entity_id) {
if group.sync_enabled {
log::info!("Group {} was deleted remotely, deleting locally", entity_id);
log::info!("Group deleted remotely, removed locally group={entity_id}");
let _ = group_manager.delete_group_internal(&entity_id);
let _ = events::emit("groups-changed", ());
}
@@ -968,7 +1009,7 @@ impl SyncScheduler {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
if let Ok(vpn) = storage.load_config(&entity_id) {
if vpn.sync_enabled {
log::info!("VPN {} was deleted remotely, deleting locally", entity_id);
log::info!("VPN deleted remotely, removed locally vpn={entity_id}");
let _ = storage.delete_config(&entity_id);
let _ = events::emit("vpn-configs-changed", ());
}
@@ -978,10 +1019,7 @@ impl SyncScheduler {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
if let Ok(ext) = manager.get_extension(&entity_id) {
if ext.sync_enabled {
log::info!(
"Extension {} was deleted remotely, deleting locally",
entity_id
);
log::info!("Extension deleted remotely, removed locally extension={entity_id}");
let _ = manager.delete_extension_internal(&entity_id);
let _ = events::emit("extensions-changed", ());
}
@@ -992,8 +1030,7 @@ impl SyncScheduler {
if let Ok(group) = manager.get_group(&entity_id) {
if group.sync_enabled {
log::info!(
"Extension group {} was deleted remotely, deleting locally",
entity_id
"Extension group deleted remotely, removed locally extension_group={entity_id}"
);
let _ = manager.delete_group_internal(&entity_id);
let _ = events::emit("extensions-changed", ());
+40 -25
View File
@@ -1,4 +1,5 @@
use crate::events;
use crate::log_streak::Streak;
use crate::settings_manager::SettingsManager;
use reqwest::Client;
use serde::Deserialize;
@@ -41,6 +42,10 @@ const RECONNECT_BASE_DELAY: Duration = Duration::from_secs(6);
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(600);
const STOP_POLL_INTERVAL: Duration = Duration::from_secs(1);
static SSE_CONNECT: Streak = Streak::new(module_path!(), "Sync SSE connect");
// Cloud token failures are already logged by `cloud_auth`.
static SELF_HOSTED_TOKEN: Streak = Streak::new(module_path!(), "Self-hosted sync token read");
#[derive(Debug)]
enum StreamFailure {
Refused(u16),
@@ -85,7 +90,7 @@ fn stream_failure(status: u16, headers: &reqwest::header::HeaderMap) -> StreamFa
match status {
401 | 403 => StreamFailure::Refused(status),
429 => StreamFailure::RateLimited(crate::cloud_auth::retry_after(headers)),
_ => StreamFailure::Failed(format!("SSE connection failed with status: {status}")),
_ => StreamFailure::Failed(format!("status={status}")),
}
}
@@ -133,8 +138,7 @@ impl SyncSubscription {
let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string();
let token = crate::cloud_auth::CLOUD_AUTH
.get_or_refresh_sync_token()
.await
.map_err(|e| format!("Failed to get cloud sync token: {e}"))?;
.await?;
let Some(token) = token else {
return Ok(None);
};
@@ -191,6 +195,7 @@ impl SyncSubscription {
tokio::spawn(async move {
let mut refused: Option<String> = None;
let mut failures: u32 = 0;
let mut stop_reason = "requested";
while running.load(Ordering::SeqCst) {
let mut wait_at_least = Duration::ZERO;
@@ -211,10 +216,10 @@ impl SyncSubscription {
}
match outcome {
Ok(()) => {
log::info!("SSE connection closed gracefully");
log::debug!("Sync SSE stream closed by server");
}
Err(StreamFailure::Refused(status)) => {
log::warn!("Sync subscription refused with status {status}");
log::warn!("Sync subscription refused status={status}");
if matches!(source, TokenSource::Cloud) {
crate::cloud_auth::CloudAuthManager::discard_cloud_sync_token(&current);
}
@@ -222,11 +227,11 @@ impl SyncSubscription {
token = None;
}
Err(StreamFailure::RateLimited(retry)) => {
log::warn!("Sync subscription rate limited for {}s", retry.as_secs());
SSE_CONNECT.failed(format!("rate limited retry_after_s={}", retry.as_secs()));
wait_at_least = retry;
}
Err(StreamFailure::Failed(e)) => {
log::warn!("SSE connection error: {e}");
SSE_CONNECT.failed(e);
}
}
}
@@ -237,7 +242,7 @@ impl SyncSubscription {
failures = failures.saturating_add(1);
let delay = reconnect_delay(failures).max(wait_at_least);
log::debug!("Reconnecting the sync subscription in {}s", delay.as_secs());
log::debug!("Sync SSE reconnect scheduled delay_s={}", delay.as_secs());
sleep_while_running(&running, delay).await;
if running.load(Ordering::SeqCst) {
@@ -246,8 +251,11 @@ impl SyncSubscription {
// the construction-time token otherwise produces an endless 401
// reconnect loop until the app is restarted.
let fetched = Self::fetch_sync_token(source, &app_handle).await;
if let Err(e) = &fetched {
log::warn!("Failed to refresh sync token: {e}");
if matches!(source, TokenSource::SelfHosted) {
match &fetched {
Ok(_) => SELF_HOSTED_TOKEN.succeeded(),
Err(e) => SELF_HOSTED_TOKEN.failed(e),
}
}
match next_token(refused.as_deref(), fetched) {
TokenStep::Use(fresh) => {
@@ -256,7 +264,7 @@ impl SyncSubscription {
}
TokenStep::Keep | TokenStep::Wait => {}
TokenStep::Stop => {
log::info!("Sync token no longer available; stopping subscription");
stop_reason = "no_token";
break;
}
}
@@ -264,7 +272,7 @@ impl SyncSubscription {
}
running.store(false, Ordering::SeqCst);
log::info!("Sync subscription stopped");
log::info!("Sync subscription stopped reason={stop_reason}");
});
}
@@ -275,14 +283,15 @@ impl SyncSubscription {
app_handle: &tauri::AppHandle,
) -> Result<Option<String>, String> {
match source {
TokenSource::Cloud => crate::cloud_auth::CLOUD_AUTH
.get_or_refresh_sync_token()
.await
.map_err(|e| format!("Failed to refresh cloud sync token: {e}")),
TokenSource::Cloud => {
crate::cloud_auth::CLOUD_AUTH
.get_or_refresh_sync_token()
.await
}
TokenSource::SelfHosted => SettingsManager::instance()
.get_sync_token(app_handle)
.await
.map_err(|e| format!("Failed to refresh self-hosted sync token: {e}")),
.map_err(|e| e.to_string()),
}
}
@@ -302,7 +311,7 @@ impl SyncSubscription {
.header("Accept", "text/event-stream")
.send()
.await
.map_err(|e| StreamFailure::Failed(format!("Failed to connect to SSE: {e}")))?;
.map_err(|e| StreamFailure::Failed(e.to_string()))?;
if !response.status().is_success() {
return Err(stream_failure(
@@ -312,17 +321,24 @@ impl SyncSubscription {
}
*connected = true;
log::info!("Connected to sync subscription");
log::debug!("Sync SSE connected");
let _ = events::emit("sync-subscription-status", "connected");
let mut buffer = String::new();
let mut bytes_stream = response.bytes_stream();
// A proxy can accept the connection and cut it before any data; only data
// proves the link works, so that is what ends a failure streak.
let mut proven = false;
use futures_util::StreamExt;
while running.load(Ordering::SeqCst) {
match tokio::time::timeout(Duration::from_secs(60), bytes_stream.next()).await {
Ok(Some(Ok(bytes))) => {
if !proven {
proven = true;
SSE_CONNECT.succeeded();
}
let chunk = String::from_utf8_lossy(&bytes);
buffer.push_str(&chunk);
@@ -336,13 +352,13 @@ impl SyncSubscription {
}
}
Ok(Some(Err(e))) => {
return Err(StreamFailure::Failed(format!("SSE stream error: {e}")));
return Err(StreamFailure::Failed(format!("stream: {e}")));
}
Ok(None) => {
return Ok(());
}
Err(_) => {
log::debug!("SSE timeout, continuing...");
log::debug!("Sync SSE idle for 60s");
}
}
}
@@ -463,7 +479,7 @@ impl SyncSubscription {
};
if let Some(item) = work_item {
log::debug!("Queueing sync work: {:?}", item);
log::debug!("Sync work queued item={item:?}");
let _ = work_tx.send(item);
}
}
@@ -511,9 +527,9 @@ impl SubscriptionManager {
if let Some(sub) = subscription {
sub.start(app_handle).await;
self.subscription = Some(sub);
log::info!("Sync subscription manager started");
log::info!("Sync subscription started");
} else {
log::debug!("Sync not configured, subscription not started");
log::debug!("Sync subscription not started: sync not configured");
}
Ok(())
@@ -524,7 +540,6 @@ impl SubscriptionManager {
sub.stop();
}
self.subscription = None;
log::info!("Sync subscription manager stopped");
}
pub fn is_running(&self) -> bool {
+70 -55
View File
@@ -378,16 +378,17 @@ impl SynchronizerManager {
let session_id = uuid::Uuid::new_v4().to_string();
log::info!(
"Synchronizer: launching leader '{}' and {} followers",
leader.name,
follower_profiles.len()
);
// Launch leader first so it gets focus
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None, None)
.await
.map_err(|e| format!("Failed to launch leader: {e}"))?;
.map_err(|e| {
// The launch logged its own failure; its text can carry the name.
log::error!(
"Synchronizer start failed step=launch_leader profile={}",
leader.id
);
format!("Failed to launch leader: {e}")
})?;
// Launch followers in parallel batches of MAX_CONCURRENT_LAUNCHES
for chunk in follower_profiles.chunks(MAX_CONCURRENT_LAUNCHES) {
@@ -398,14 +399,14 @@ impl SynchronizerManager {
set.spawn(async move {
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None, None)
.await
.map_err(|e| (fp.name.clone(), e.to_string()))
.map_err(|e| (fp.id, fp.name.clone(), e.to_string()))
});
}
while let Some(result) = set.join_next().await {
match result {
Ok(Ok(_)) => {}
Ok(Err((name, e))) => {
log::error!("Failed to launch follower '{name}': {e}");
Ok(Err((id, name, e))) => {
log::error!("Synchronizer start failed step=launch_follower profile={id}");
// Kill leader and all already-launched followers
let _ =
crate::browser_runner::kill_browser_profile(app_handle.clone(), leader.clone()).await;
@@ -416,7 +417,7 @@ impl SynchronizerManager {
return Err(format!("Failed to launch follower '{name}': {e}"));
}
Err(e) => {
log::error!("Launch task panicked: {e}");
log::error!("Synchronizer start failed step=launch_task err=\"{e}\"");
let _ =
crate::browser_runner::kill_browser_profile(app_handle.clone(), leader.clone()).await;
return Err(format!("Launch task panicked: {e}"));
@@ -473,10 +474,7 @@ impl SynchronizerManager {
.chain(follower_profile_ids.iter().cloned())
.collect();
log::info!("Synchronizer: spawning CDP listener task");
tokio::spawn(async move {
log::info!("Synchronizer: CDP listener task started");
if let Err(e) = Self::run_session_loop(
ah.clone(),
manager.clone(),
@@ -491,7 +489,10 @@ impl SynchronizerManager {
)
.await
{
log::error!("Synchronizer session {sid} error: {e}");
log::error!(
"Synchronizer session failed session={} err=\"{e}\"",
ShortId(&sid)
);
// Kill all profiles on error (leader + followers)
for pid in &all_profile_ids {
if let Ok(p) = Self::get_profile(pid) {
@@ -509,8 +510,15 @@ impl SynchronizerManager {
match tokio::time::timeout(std::time::Duration::from_secs(90), ready_rx).await {
Ok(Ok(Ok(()))) => Ok(info),
Ok(Ok(Err(e))) => Err(format!("Synchronizer setup failed: {e}")),
// The session task logged why it ended.
Ok(Err(_)) => Err("Synchronizer setup channel closed unexpectedly".to_string()),
Err(_) => Err("Synchronizer setup timed out".to_string()),
Err(_) => {
log::error!(
"Synchronizer start failed step=setup session={} err=\"timed out after 90s\"",
ShortId(&session_id)
);
Err("Synchronizer setup timed out".to_string())
}
}
}
@@ -568,18 +576,14 @@ impl SynchronizerManager {
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
log::info!("Synchronizer: run_session_loop started, waiting 1s for browsers");
let started = std::time::Instant::now();
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// Connect to leader page-level target for reliable event capture
log::info!("Synchronizer: getting leader CDP port");
let leader_profile = Self::get_profile(&leader_profile_id)?;
let leader_port = Self::get_cdp_port(&leader_profile).await?;
log::info!("Synchronizer: leader CDP port = {leader_port}, getting WS URL");
let leader_ws_url = Self::get_page_ws_url(leader_port).await?;
log::info!("Synchronizer: connecting to leader page");
let (mut ws_stream, _) = connect_async(&leader_ws_url)
.await
.map_err(|e| format!("Failed to connect to leader CDP: {e}"))?;
@@ -649,16 +653,13 @@ impl SynchronizerManager {
)
.await
{
Ok(_) => log::info!("Synchronizer: {method} OK"),
Ok(_) => {}
Err(e) => {
log::error!("Synchronizer: {method} FAILED: {e}");
return Err(format!("{method} failed: {e}"));
}
}
}
log::info!("Synchronizer: input capture enabled");
// Get leader window size and resize all followers to match
let leader_bounds = send_cmd(
&mut ws_stream,
@@ -675,7 +676,7 @@ impl SynchronizerManager {
let width = bounds.get("width").and_then(|v| v.as_i64()).unwrap_or(0);
let height = bounds.get("height").and_then(|v| v.as_i64()).unwrap_or(0);
if width > 0 && height > 0 {
log::info!("Synchronizer: leader window size {width}x{height}, resizing followers");
log::debug!("Synchronizer resizing followers width={width} height={height}");
for fid in &follower_profile_ids {
if let Ok(fp) = Self::get_profile(fid) {
if let Ok(port) = Self::get_cdp_port(&fp).await {
@@ -709,8 +710,6 @@ impl SynchronizerManager {
}
}
log::info!("Synchronizer: opening persistent connections to followers");
// Open persistent WebSocket connections to each follower and create event channels.
// Each follower gets a dedicated replay task with a long-lived WS connection.
let mut follower_senders: HashMap<String, tokio::sync::mpsc::UnboundedSender<CapturedEvent>> =
@@ -723,7 +722,6 @@ impl SynchronizerManager {
Ok(url) => {
match tokio_tungstenite::connect_async(&url).await {
Ok((ws, _)) => {
log::info!("Synchronizer: follower connected");
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<CapturedEvent>();
follower_senders.insert(fid.clone(), tx);
@@ -737,29 +735,30 @@ impl SynchronizerManager {
});
}
Err(e) => log::warn!(
"Synchronizer: failed to connect to follower {}: {e}",
fp.name
"Synchronizer follower not connected step=connect profile={fid} err=\"{e}\""
),
}
}
Err(e) => log::warn!(
"Synchronizer: failed to get WS URL for follower {}: {e}",
fp.name
"Synchronizer follower not connected step=ws_url profile={fid} err=\"{e}\""
),
},
Err(e) => log::warn!(
"Synchronizer: failed to get CDP port for follower {}: {e}",
fp.name
"Synchronizer follower not connected step=cdp_port profile={fid} err=\"{e}\""
),
},
Err(e) => log::warn!("Synchronizer: failed to get follower profile {fid}: {e}"),
Err(e) => {
log::warn!("Synchronizer follower not connected step=profile profile={fid} err=\"{e}\"")
}
}
}
log::info!(
"Synchronizer: {} of {} followers connected",
"Synchronizer session started session={} leader={leader_profile_id} leader_cdp_port={leader_port} followers_connected={}/{} elapsed_ms={}",
ShortId(&session_id),
follower_senders.len(),
follower_profile_ids.len()
follower_profile_ids.len(),
started.elapsed().as_millis()
);
// Track when the last user interaction was captured (for suppressing click-caused nav replay)
@@ -774,11 +773,12 @@ impl SynchronizerManager {
}
// Main event loop — listen for Wayfern.inputCaptured events
let end_reason: String;
loop {
tokio::select! {
_ = cancel_rx.changed() => {
if *cancel_rx.borrow() {
log::info!("Synchronizer session {}: cancelled", ShortId(&session_id));
end_reason = "cancelled".to_string();
break;
}
}
@@ -795,7 +795,10 @@ impl SynchronizerManager {
// Log CDP command response errors
if let Some(id) = value.get("id") {
if let Some(error) = value.get("error") {
log::warn!("Synchronizer: CDP command {id} error: {error}");
log::warn!(
"Synchronizer leader CDP command failed session={} id={id} err={error}",
ShortId(&session_id)
);
}
}
@@ -815,11 +818,11 @@ impl SynchronizerManager {
}
Some(Ok(_)) => {} // pings, binary, etc.
Some(Err(e)) => {
log::error!("Synchronizer: leader WebSocket error: {e}");
end_reason = format!("leader_ws_error err=\"{e}\"");
break;
}
None => {
log::info!("Synchronizer: leader WebSocket closed (browser closed)");
end_reason = "leader_closed".to_string();
break;
}
}
@@ -828,10 +831,6 @@ impl SynchronizerManager {
}
// Leader closed or session cancelled — kill all followers
log::info!(
"Synchronizer session {}: stopping all followers",
ShortId(&session_id)
);
let follower_ids: Vec<String> = {
let inner = manager.lock().await;
if let Some(session) = inner.sessions.get(&session_id) {
@@ -845,11 +844,22 @@ impl SynchronizerManager {
}
};
let followers_stopped = follower_ids.len();
for fid in follower_ids {
if let Ok(fp) = Self::get_profile(&fid) {
let _ = crate::browser_runner::kill_browser_profile(app_handle.clone(), fp).await;
}
}
log::log!(
if end_reason.starts_with("leader_ws_error") {
log::Level::Warn
} else {
log::Level::Info
},
"Synchronizer session ended session={} reason={end_reason} followers_stopped={followers_stopped} duration_s={}",
ShortId(&session_id),
started.elapsed().as_secs()
);
Ok(())
}
@@ -876,7 +886,7 @@ impl SynchronizerManager {
return;
}
if let Ok(event) = serde_json::from_value::<CapturedEvent>(params.clone()) {
log::info!("Synchronizer: captured {event_type}");
log::trace!("Synchronizer captured event={event_type}");
Self::fan_out(gate, follower_senders, &event);
}
}
@@ -890,7 +900,7 @@ impl SynchronizerManager {
if is_top {
if let Some(url) = frame.get("url").and_then(|v| v.as_str()) {
if !url.starts_with("about:") && !url.starts_with("chrome://") {
log::info!("Synchronizer: replaying address-bar navigation");
log::trace!("Synchronizer replaying address-bar navigation");
let nav_event = CapturedEvent {
event_type: "navigate".to_string(),
url: Some(url.to_string()),
@@ -926,7 +936,7 @@ impl SynchronizerManager {
event: &CapturedEvent,
) {
let Ok(gate) = gate.read() else {
log::warn!("Synchronizer: mirroring gate is poisoned; dropping the event");
log::warn!("Synchronizer mirroring gate poisoned; event dropped");
return;
};
if gate.is_paused() {
@@ -1039,7 +1049,10 @@ impl SynchronizerManager {
if let Some((method, params)) = command {
let cmd = serde_json::json!({ "id": cmd_id, "method": method, "params": params });
if let Err(e) = ws.send(Message::Text(cmd.to_string().into())).await {
log::warn!("Synchronizer: follower {follower_id} send failed: {e}");
log::warn!(
"Synchronizer follower desynced: send failed session={} profile={follower_id} err=\"{e}\"",
ShortId(&session_id)
);
// Mark as desynced
let mut inner = manager.lock().await;
if let Some(session) = inner.sessions.get_mut(&session_id) {
@@ -1149,9 +1162,9 @@ impl SynchronizerManager {
let info = session.info();
let _ = app_handle.emit("sync-session-changed", &info);
log::info!(
"Synchronizer session {}: mirroring {}",
ShortId(session_id),
if paused { "paused" } else { "resumed" }
"Synchronizer mirroring {} session={}",
if paused { "paused" } else { "resumed" },
ShortId(session_id)
);
Ok(info)
}
@@ -1236,7 +1249,9 @@ impl SynchronizerManager {
for (follower_id, rect) in follower_ids.iter().zip(rects) {
match Self::place_window(follower_id, rect).await {
Ok(()) => placed += 1,
Err(e) => log::warn!("Synchronizer: could not place follower {follower_id}: {e}"),
Err(e) => {
log::warn!("Synchronizer window placement failed profile={follower_id} err=\"{e}\"")
}
}
}
@@ -1244,7 +1259,7 @@ impl SynchronizerManager {
return Err(serde_json::json!({ "code": "SYNC_ARRANGE_FAILED" }).to_string());
}
log::info!(
"Synchronizer session {}: placed {placed} of {} windows",
"Synchronizer windows arranged session={} placed={placed}/{}",
ShortId(session_id),
follower_ids.len()
);
+24 -19
View File
@@ -112,8 +112,8 @@ async fn release_attempt(profile_id: &str) -> Option<Duration> {
ReleaseStep::Retry => Some(Duration::ZERO),
ReleaseStep::GiveUp => {
log::warn!(
"Profile lock release for {profile_id} was refused ({})",
response.status()
"Profile lock release refused profile={profile_id} status={}",
response.status().as_u16()
);
None
}
@@ -121,7 +121,7 @@ async fn release_attempt(profile_id: &str) -> Option<Duration> {
Err(AuthorizedError::SignedOut) => None,
Err(AuthorizedError::RateLimited(wait)) => Some(wait),
Err(AuthorizedError::Transport(e)) => {
log::debug!("Profile lock release for {profile_id} failed: {e}");
log::debug!("Profile lock release attempt failed profile={profile_id} err=\"{e}\"");
Some(Duration::ZERO)
}
}
@@ -131,7 +131,7 @@ async fn release_with_retry(profile_id: String, mut pending: Option<Duration>) {
let mut attempt = 0;
while let Some(wait) = pending {
let Some(delay) = release_retry_delay(attempt) else {
log::warn!("Could not release the profile lock for {profile_id}");
log::warn!("Profile lock release gave up after retries profile={profile_id}");
return;
};
attempt += 1;
@@ -169,22 +169,21 @@ impl ProfileLockManager {
}
pub async fn connect(&self) {
log::info!("Connecting profile lock manager");
{
let mut c = self.connected.lock().await;
*c = true;
}
if let Err(e) = self.fetch_locks().await {
log::warn!("Failed to fetch initial profile locks: {e}");
match self.fetch_locks().await {
Ok(_) => log::info!("Profile lock manager connected"),
Err(e) => log::warn!("Profile lock manager connected, initial lock fetch failed err=\"{e}\""),
}
self.start_heartbeat_loop().await;
}
pub async fn disconnect(&self) {
log::info!("Disconnecting profile lock manager");
log::info!("Profile lock manager disconnected");
{
let mut handle = self.heartbeat_handle.lock().await;
@@ -251,19 +250,22 @@ impl ProfileLockManager {
.await
.map_err(|e| {
self.note_failure(&e);
log::warn!("Failed to acquire profile lock for {profile_id}: {e}");
log::warn!("Profile lock not acquired profile={profile_id} err=\"{e}\"");
crate::backend_error("PROFILE_LOCK_UNAVAILABLE")
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
log::warn!("Profile lock acquisition for {profile_id} failed ({status}): {body}");
log::warn!(
"Profile lock not acquired profile={profile_id} status={} err=\"{body}\"",
status.as_u16()
);
return Err(crate::backend_error("PROFILE_LOCK_UNAVAILABLE"));
}
let result: AcquireLockResponse = response.json().await.map_err(|e| {
log::warn!("Could not parse the profile lock response for {profile_id}: {e}");
log::warn!("Profile lock response unparsable profile={profile_id} err=\"{e}\"");
crate::backend_error("PROFILE_LOCK_UNAVAILABLE")
})?;
@@ -331,7 +333,10 @@ impl ProfileLockManager {
.await
.is_err()
{
log::warn!("Timed out releasing profile locks");
log::warn!(
"Profile lock release timed out limit_ms={}",
limit.as_millis()
);
}
}
@@ -406,9 +411,9 @@ impl ProfileLockManager {
return;
}
if crate::browser_runner::browser_is_running_for(profile_id) {
log::warn!("Profile lock for {profile_id} was not renewed; taking it again");
log::warn!("Profile lock lost while running, taking it again profile={profile_id}");
if let Err(e) = self.acquire_lock(profile_id).await {
log::warn!("Could not take the profile lock for {profile_id} again: {e}");
log::warn!("Profile lock not taken again profile={profile_id} err=\"{e}\"");
self.forget(profile_id).await;
emit_lock_change(Some(profile_id), "released");
}
@@ -445,7 +450,7 @@ impl ProfileLockManager {
Ok(Renewal::Refreshed) => {}
Ok(Renewal::Lost) => PROFILE_LOCK.recover_lost_lock(&profile_id).await,
Ok(Renewal::Failed) => {
log::debug!("Profile lock heartbeat for {profile_id} was not accepted");
log::debug!("Profile lock heartbeat not accepted profile={profile_id}");
}
Err(AuthorizedError::SignedOut) => {
signed_out = true;
@@ -453,7 +458,7 @@ impl ProfileLockManager {
}
Err(e) => {
PROFILE_LOCK.note_failure(&e);
log::debug!("Profile lock heartbeat for {profile_id} failed: {e}");
log::debug!("Profile lock heartbeat failed profile={profile_id} err=\"{e}\"");
if PROFILE_LOCK.is_paused() {
break;
}
@@ -467,7 +472,7 @@ impl ProfileLockManager {
// Refresh lock state from server
if let Err(e) = PROFILE_LOCK.fetch_locks().await {
log::debug!("Failed to refresh profile locks: {e}");
log::debug!("Profile lock refresh failed err=\"{e}\"");
}
}
});
@@ -495,7 +500,7 @@ fn lock_conflict_error(
if holder.is_some_and(|id| id.contains(VM_HOLDER_SEPARATOR)) {
// The user's own remote session. Saying "in use by you@example.com" here,
// which is what the raw backend message did, reads as a bug.
log::info!("Profile {profile_id} is held by a remote session");
log::info!("Profile lock held by a remote session profile={profile_id}");
return crate::backend_error("PROFILE_RUNNING_REMOTELY");
}
match holder_email {
+5 -2
View File
@@ -428,14 +428,17 @@ fn collect_traffic_stats() -> HashMap<String, TrafficStats> {
for key in &dirty_keys {
if let Some(stats) = stats_map.get(key) {
if let Err(e) = save_traffic_stats(stats) {
log::warn!("Failed to save merged traffic stats: {}", e);
log::warn!("Merged traffic stats save failed err=\"{e}\"");
}
}
}
for path in files_to_delete {
if let Err(e) = fs::remove_file(&path) {
log::warn!("Failed to delete old traffic stats file {:?}: {}", path, e);
log::warn!(
"Old traffic stats file delete failed path=\"{}\" err=\"{e}\"",
path.display()
);
}
}
+1 -1
View File
@@ -213,7 +213,7 @@ pub fn open(file: &Path, magic: &[u8; 6]) -> Result<Option<String>, String> {
Some(secret) => {
if let Err(e) = seal_with(file, magic, &secret, &key) {
log::warn!(
"Could not re-seal {} under the vault key: {e}",
"Re-seal under vault key failed path=\"{}\" err=\"{e}\"",
file.display()
);
}
+52 -49
View File
@@ -121,21 +121,12 @@ impl VersionUpdater {
// 2. More than 3 hours have passed since last update
let should_update = state.last_update_time == 0 || elapsed_secs >= update_interval_secs;
if should_update {
log::debug!(
"Background update needed: last_update={}, elapsed={}h, required={}h",
state.last_update_time,
elapsed_secs / 3600,
state.update_interval_hours
);
} else {
log::debug!(
"Background update not needed: last_update={}, elapsed={}h, required={}h",
state.last_update_time,
elapsed_secs / 3600,
state.update_interval_hours
);
}
log::debug!(
"Browser version refresh due={should_update} last_update={} elapsed_h={} interval_h={}",
state.last_update_time,
elapsed_secs / 3600,
state.update_interval_hours
);
should_update
}
@@ -145,9 +136,10 @@ impl VersionUpdater {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Always check for updates on launch
if let Some(ref app_handle) = self.app_handle {
log::info!("Running startup version update...");
match self.update_all_browser_versions(app_handle).await {
match self
.update_all_browser_versions(app_handle, "startup")
.await
{
Ok(_) => {
// Update the persistent state after successful update
let state = BackgroundUpdateState {
@@ -156,13 +148,11 @@ impl VersionUpdater {
};
if let Err(e) = Self::save_background_update_state(&state) {
log::error!("Failed to save background update state: {e}");
} else {
log::info!("Startup version update completed successfully");
log::warn!("Browser version refresh state save failed err=\"{e}\"");
}
}
Err(e) => {
log::error!("Startup version update failed: {e}");
log::error!("Browser version refresh failed trigger=startup err=\"{e}\"");
return Err(e);
}
}
@@ -176,14 +166,8 @@ impl VersionUpdater {
pub async fn start_background_updates(
&self,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!(
"Starting background version update service (checking every 5 minutes for 3-hour intervals)"
);
// Run initial startup check
if let Err(e) = self.check_and_run_startup_update().await {
eprintln!("Startup version update failed: {e}");
}
// Logged inside; the error is not repeated here.
let _ = self.check_and_run_startup_update().await;
Ok(())
}
@@ -200,14 +184,14 @@ impl VersionUpdater {
continue;
}
println!("Starting background version update...");
// Get the updater instance for this update cycle
let updater = get_version_updater();
let result = {
let updater_guard = updater.lock().await;
if let Some(ref app_handle) = updater_guard.app_handle {
updater_guard.update_all_browser_versions(app_handle).await
updater_guard
.update_all_browser_versions(app_handle, "interval")
.await
} else {
Err("App handle not available for background update".into())
}
@@ -222,13 +206,11 @@ impl VersionUpdater {
};
if let Err(e) = Self::save_background_update_state(&state) {
eprintln!("Failed to save background update state: {e}");
} else {
println!("Background version update completed successfully");
log::warn!("Browser version refresh state save failed err=\"{e}\"");
}
}
Err(e) => {
eprintln!("Background version update failed: {e}");
log::error!("Browser version refresh failed trigger=interval err=\"{e}\"");
// Try to emit error event if we have an app handle
let updater_guard = updater.lock().await;
@@ -251,7 +233,9 @@ impl VersionUpdater {
async fn update_all_browser_versions(
&self,
app_handle: &tauri::AppHandle,
trigger: &str,
) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
let started = std::time::Instant::now();
let supported_browsers = self.browser_version_manager.get_supported_browsers();
// Only fetch versions for active browsers (wayfern) plus any
@@ -285,12 +269,11 @@ impl VersionUpdater {
};
if let Err(e) = events::emit("version-update-progress", &initial_progress) {
log::error!("Failed to emit initial progress: {e}");
log::warn!("Event emit failed event=version-update-progress err=\"{e}\"");
}
let mut failures = Vec::new();
for (index, browser) in supported_browsers.iter().enumerate() {
log::debug!("Updating browser versions for: {browser}");
// Emit progress update for current browser
let progress = VersionUpdateProgress {
current_browser: browser.clone(),
@@ -302,7 +285,7 @@ impl VersionUpdater {
};
if let Err(e) = events::emit("version-update-progress", &progress) {
log::error!("Failed to emit progress for {browser}: {e}");
log::warn!("Event emit failed event=version-update-progress browser={browser} err=\"{e}\"");
}
match self.update_browser_versions(browser).await {
@@ -328,10 +311,13 @@ impl VersionUpdater {
};
if let Err(e) = events::emit("version-update-progress", &progress) {
log::error!("Failed to emit progress with versions for {browser}: {e}");
log::warn!(
"Event emit failed event=version-update-progress browser={browser} err=\"{e}\""
);
}
}
Err(e) => {
failures.push(format!("browser={browser} {e}"));
results.push(BackgroundUpdateResult {
browser: browser.clone(),
new_versions_count: 0,
@@ -354,14 +340,27 @@ impl VersionUpdater {
};
if let Err(e) = events::emit("version-update-progress", &final_progress) {
eprintln!("Failed to emit completion progress: {e}");
log::warn!("Event emit failed event=version-update-progress err=\"{e}\"");
}
if failures.is_empty() {
VERSION_REFRESH_STREAK.succeeded();
} else {
VERSION_REFRESH_STREAK.failed(failures.join("; "));
}
log::log!(
if total_new_versions > 0 {
log::Level::Info
} else {
log::Level::Debug
},
"Browser version refresh trigger={trigger} browsers={total_browsers} new_versions={total_new_versions} failed={} elapsed_ms={}",
failures.len(),
started.elapsed().as_millis()
);
// Always check for auto-updates — profiles may still be on older versions
// even if no new versions were found in the cache this cycle
println!(
"Checking for browser auto-updates (found {total_new_versions} new versions in cache)..."
);
self
.auto_updater
.check_for_updates_with_progress(app_handle)
@@ -384,7 +383,9 @@ impl VersionUpdater {
&self,
app_handle: &tauri::AppHandle,
) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
let results = self.update_all_browser_versions(app_handle).await?;
let results = self
.update_all_browser_versions(app_handle, "manual")
.await?;
// Update the persistent state after successful manual update
let state = BackgroundUpdateState {
@@ -393,7 +394,7 @@ impl VersionUpdater {
};
if let Err(e) = Self::save_background_update_state(&state) {
log::error!("Failed to save background update state after manual update: {e}");
log::warn!("Browser version refresh state save failed err=\"{e}\"");
}
Ok(results)
@@ -425,6 +426,8 @@ impl VersionUpdater {
// Global instance
static VERSION_UPDATER: OnceLock<Arc<Mutex<VersionUpdater>>> = OnceLock::new();
static VERSION_REFRESH_STREAK: crate::log_streak::Streak =
crate::log_streak::Streak::new(module_path!(), "Browser version refresh");
pub fn get_version_updater() -> Arc<Mutex<VersionUpdater>> {
VERSION_UPDATER
@@ -506,7 +509,7 @@ pub async fn clear_all_version_cache_and_refetch(
.auto_updater
.save_auto_update_state(&final_state)
{
log::warn!("Failed to re-enable browsers after cache clear: {e}");
log::warn!("Re-enabling browsers after cache clear failed err=\"{e}\"");
}
result?;
+27 -26
View File
@@ -109,7 +109,7 @@ impl WgDevice {
match result {
TunnResult::WriteToNetwork(packet) => {
if let Err(e) = self.udp_socket.send_to(packet, self.peer_addr) {
log::error!("[wg] udp send_to failed: {e}");
crate::proxy_server::warn_throttled(&format!("WireGuard UDP send failed err=\"{e}\""));
}
}
TunnResult::Done => {
@@ -117,13 +117,14 @@ impl WgDevice {
// complete); silently drop. smoltcp will retransmit.
}
TunnResult::Err(e) => {
log::error!(
"[wg] encapsulate error for {}B IP packet: {e:?}",
ip_packet.len()
);
crate::proxy_server::warn_throttled(&format!(
"WireGuard encapsulate failed err=\"{e:?}\""
));
}
TunnResult::WriteToTunnelV4(_, _) | TunnResult::WriteToTunnelV6(_, _) => {
log::error!("[wg] encapsulate returned unexpected WriteToTunnel — bug?");
crate::proxy_server::warn_throttled(
"WireGuard encapsulate returned WriteToTunnel; unexpected, packet dropped",
);
}
}
}
@@ -337,11 +338,12 @@ impl WireGuardSocks5Server {
})
}
/// Returns how many attempts the handshake took.
fn do_handshake(
tunn: &mut Tunn,
socket: &UdpSocket,
peer_addr: SocketAddr,
) -> Result<(), VpnError> {
) -> Result<u32, VpnError> {
socket
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.map_err(|e| VpnError::Connection(format!("Failed to set timeout: {e}")))?;
@@ -384,8 +386,8 @@ impl WireGuardSocks5Server {
TunnResult::Done => {}
TunnResult::Err(e) => {
last_error = format!("handshake response error: {e:?}");
log::warn!(
"[vpn-worker] Handshake attempt {attempt}/{max_attempts} failed: {last_error}"
log::debug!(
"WireGuard handshake attempt failed attempt={attempt}/{max_attempts} err=\"{last_error}\""
);
continue;
}
@@ -395,11 +397,11 @@ impl WireGuardSocks5Server {
socket
.set_read_timeout(None)
.map_err(|e| VpnError::Connection(format!("Failed to clear timeout: {e}")))?;
return Ok(());
return Ok(attempt);
}
Err(e) if attempt < max_attempts => {
log::warn!(
"[vpn-worker] Handshake attempt {attempt}/{max_attempts} timed out: {e}, retrying"
log::debug!(
"WireGuard handshake attempt timed out attempt={attempt}/{max_attempts} err=\"{e}\""
);
last_error = format!("timeout: {e}");
continue;
@@ -420,20 +422,19 @@ impl WireGuardSocks5Server {
config_id: String,
config_path: Option<std::path::PathBuf>,
) -> Result<(), VpnError> {
let started = std::time::Instant::now();
let peer_addr = self.resolve_endpoint()?;
let mut tunn = self.create_tunnel()?;
let udp_socket = UdpSocket::bind("0.0.0.0:0")
.map_err(|e| VpnError::Connection(format!("Failed to create UDP socket: {e}")))?;
Self::do_handshake(&mut tunn, &udp_socket, peer_addr)?;
let handshake_attempts = Self::do_handshake(&mut tunn, &udp_socket, peer_addr)?;
udp_socket
.set_nonblocking(true)
.map_err(|e| VpnError::Connection(format!("Failed to set non-blocking: {e}")))?;
log::info!("[vpn-worker] WireGuard handshake completed");
let local_addrs = parse_cidr_addresses(&self.config.address)?;
let tunn_arc = Arc::new(Mutex::new(tunn));
@@ -508,6 +509,9 @@ impl WireGuardSocks5Server {
.or_else(|| crate::vpn_worker_storage::get_vpn_worker_config(&config_id)),
None => crate::vpn_worker_storage::get_vpn_worker_config(&config_id),
};
let vpn_id = updated
.as_ref()
.map_or_else(|| "-".to_string(), |wc| wc.vpn_id.clone());
if let Some(mut wc) = updated {
wc.local_port = Some(actual_port);
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
@@ -518,22 +522,21 @@ impl WireGuardSocks5Server {
};
if let Err(e) = result {
log::error!(
"[vpn-worker] Failed to write back local_url to config: {} (path={:?})",
e,
"Worker config write-back failed; the app will not see this port id={config_id} path={:?} err=\"{e}\"",
config_path
);
}
} else {
log::error!(
"[vpn-worker] Could not load worker config for write-back (id={}, path={:?})",
config_id,
"Worker config write-back skipped: config not found id={config_id} path={:?}",
config_path
);
}
log::info!(
"[vpn-worker] SOCKS5 server listening on 127.0.0.1:{}",
actual_port
"Worker started kind=vpn id={config_id} pid={} listen=127.0.0.1:{actual_port} vpn={vpn_id} vpn_type=wireguard peer={peer_addr} handshake_attempts={handshake_attempts} elapsed_ms={}",
std::process::id(),
started.elapsed().as_millis()
);
// DNS resolution for domain-name CONNECT requests must go THROUGH the tunnel, never
@@ -857,11 +860,9 @@ impl WireGuardSocks5Server {
});
}
Err(e) => {
log::warn!(
"[vpn-worker] Failed to start DNS query for {}: {:?}",
domain,
e
);
crate::proxy_server::warn_throttled(&format!(
"Tunnel DNS query start failed err=\"{e:?}\""
));
let _ = conn
.tcp_stream
.try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
+2 -2
View File
@@ -295,7 +295,7 @@ impl VpnTunnel for WireGuardTunnel {
self.connected.store(true, Ordering::Release);
self.connected_at = Some(Utc::now().timestamp());
log::info!("[vpn] WireGuard tunnel {} connected", self.vpn_id);
log::info!("WireGuard tunnel connected vpn={}", self.vpn_id);
Ok(())
}
@@ -310,7 +310,7 @@ impl VpnTunnel for WireGuardTunnel {
self.socket = None;
self.connected_at = None;
log::info!("[vpn] WireGuard tunnel {} disconnected", self.vpn_id);
log::info!("WireGuard tunnel disconnected vpn={}", self.vpn_id);
Ok(())
}
@@ -93,7 +93,7 @@ fn resolve_dir_i18n(
let key = message_placeholder_key(value)?;
let default_locale = manifest.get("default_locale")?.as_str()?;
if !is_safe_locale_name(default_locale) {
log::warn!("Ignoring extension with a suspicious default_locale: {default_locale:?}");
log::warn!("Extension ignored: suspicious default_locale={default_locale:?}");
return None;
}
let messages = read_json_file(
+1 -1
View File
@@ -51,7 +51,7 @@ fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExte
return;
};
let Ok(manager) = crate::extension_manager::EXTENSION_MANAGER.lock() else {
log::warn!("VPN extension scan: extension manager lock poisoned, skipping managed extensions");
log::warn!("VPN extension scan skipped managed extensions: extension manager lock poisoned");
return;
};
let Ok(group) = manager.get_group(group_id) else {
+51 -13
View File
@@ -225,6 +225,13 @@ pub async fn start_vpn_worker_tracked(
for config in list_vpn_worker_configs() {
if !vpn_worker_alive(&config) {
if let Some(pid) = config.pid {
log::info!(
"VPN worker exited, record removed id={} pid={pid} vpn={}",
config.id,
config.vpn_id
);
}
delete_vpn_worker_config(&config.id);
}
}
@@ -278,6 +285,7 @@ pub async fn start_vpn_worker_tracked(
let _ = std::fs::set_permissions(&config_file_path, std::fs::Permissions::from_mode(0o600));
}
let started = std::time::Instant::now();
let id = generate_vpn_worker_id();
// Find an available port
@@ -319,7 +327,6 @@ pub async fn start_vpn_worker_tracked(
let log_path = std::env::temp_dir().join(format!("donut-vpn-{}.log", id));
if let Ok(file) = std::fs::File::create(&log_path) {
log::info!("VPN worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
cmd.stderr(Stdio::null());
@@ -367,7 +374,6 @@ pub async fn start_vpn_worker_tracked(
let log_path = std::env::temp_dir().join(format!("donut-vpn-{}.log", id));
if let Ok(file) = std::fs::File::create(&log_path) {
log::info!("VPN worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
cmd.stderr(Stdio::null());
@@ -390,20 +396,36 @@ pub async fn start_vpn_worker_tracked(
drop(child);
}
wait_for_vpn_worker_ready(&id)
.await
.map(|config| VpnWorkerStart {
config,
created: true,
claim: VpnLaunchClaim::take(vpn_id),
})
match wait_for_vpn_worker_ready(&id).await {
Ok(config) => {
log::info!(
"VPN worker started id={id} pid={} port={local_port} vpn={vpn_id} elapsed_ms={}",
config.pid.unwrap_or_default(),
started.elapsed().as_millis()
);
Ok(VpnWorkerStart {
config,
created: true,
claim: VpnLaunchClaim::take(vpn_id),
})
}
Err(e) => {
log::warn!(
"VPN worker did not become ready id={id} vpn={vpn_id} elapsed_ms={}",
started.elapsed().as_millis()
);
Err(e)
}
}
}
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
let config = get_vpn_worker_config(id);
if let Some(config) = config {
let mut how = "no_process";
if let Some(pid) = config.pid {
how = "already_gone";
// Only a PID still pinned to the process this record was written for is
// ours to signal. A record with no start time predates the pinning and
// came from an earlier app run, so its PID cannot be verified either.
@@ -428,15 +450,17 @@ pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
how = if is_process_running(pid) {
"still_running"
} else {
"terminated"
};
} else if is_process_running(pid) {
// Whatever holds the PID now is either an unrelated process or an
// unverifiable pre-upgrade worker; the record is forgotten instead. A
// real worker in the second case lingers until reboot, which beats
// terminating a stranger.
log::warn!(
"Not signalling VPN worker {id}: PID {pid} cannot be pinned to the recorded process (start time {:?}); forgetting the record",
config.pid_start_time
);
how = "not_signalled_pid_unverified";
}
}
@@ -444,6 +468,20 @@ pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error
let _ = std::fs::remove_file(&config.config_file_path);
delete_vpn_worker_config(id);
let level = if matches!(how, "still_running" | "not_signalled_pid_unverified") {
log::Level::Warn
} else {
log::Level::Info
};
log::log!(
level,
"VPN worker stopped id={id} pid={} vpn={} how={how}",
config
.pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "none".to_string()),
config.vpn_id
);
return Ok(true);
}
+5 -3
View File
@@ -1118,7 +1118,7 @@ pub mod vellum {
fn drop(&mut self) {
if !self.released {
log::warn!(
"[vellum] pointer {} dropped without release; the browser frees it with the session",
"Vellum pointer dropped without release, the browser frees it with the session pointer={}",
self.id
);
}
@@ -1310,14 +1310,16 @@ pub mod vellum {
Ok(value) => {
if let Err(e) = released {
log::warn!(
"[vellum] pointer {pointer_id} could not be released after a completed gesture: {e}"
"Vellum pointer release failed after a completed gesture pointer={pointer_id} err=\"{e}\""
);
}
Ok(value)
}
Err(e) => {
if let Err(release_error) = released {
log::warn!("[vellum] pointer {pointer_id} could not be released after a failed gesture: {release_error}");
log::warn!(
"Vellum pointer release failed after a failed gesture pointer={pointer_id} err=\"{release_error}\""
);
}
Err(e)
}
+29 -11
View File
@@ -317,7 +317,7 @@ async fn convert_and_save(
{
return Err(ConversionError::Changed);
}
log::info!("Stored Wayfern profile {} as an identity", stored.id);
log::info!("Profile stored as an identity profile={}", stored.id);
Ok(stored)
}
@@ -352,7 +352,7 @@ pub async fn convert_for_launch(profile: &BrowserProfile) -> Result<BrowserProfi
WayfernManager::instance(),
&current,
token.as_deref(),
&format!("profile {}", current.id),
&format!("purpose=conversion profile={}", current.id),
)
.await
.map_err(|e| wayfern_failure(&e.to_string(), "WAYFERN_FINGERPRINT_APPLY_FAILED", None))?;
@@ -389,7 +389,7 @@ fn launch_outcome(
Ok(converted) => Ok(converted),
Err(ConversionError::Failed(reason)) | Err(ConversionError::Deferred(reason)) => {
log::warn!(
"Launching Wayfern profile {} on its stored device: {reason}",
"Identity conversion failed, launching on the stored device profile={} err=\"{reason}\"",
current.id
);
Ok(current)
@@ -473,16 +473,16 @@ async fn run_pass() -> bool {
WayfernManager::instance(),
&profile,
token.as_deref(),
"stored profiles",
"purpose=conversion_pass",
)
.await
{
Ok(started) => session = Some((profile.version.clone(), started, alive)),
Ok(started) => {
PASS_BROWSER.succeeded(&format!("version={}", profile.version));
session = Some((profile.version.clone(), started, alive))
}
Err(e) => {
log::warn!(
"Could not start Wayfern {} for stored profiles: {e}",
profile.version
);
PASS_BROWSER.failed(&format!("version={}", profile.version), e);
deferred = true;
continue;
}
@@ -499,11 +499,15 @@ async fn run_pass() -> bool {
}
Err(ConversionError::Changed) => deferred = true,
Err(ConversionError::Deferred(reason)) => {
log::info!("Stored Wayfern profile {id} is unchanged for now: {reason}");
log::debug!("Identity conversion deferred profile={id} reason=\"{reason}\"");
deferred = true;
}
Err(ConversionError::Failed(reason)) => {
log::warn!("Stored Wayfern profile {id} could not be converted: {reason}");
if first_failure_for(&id.to_string()) {
log::warn!("Identity conversion failed profile={id} err=\"{reason}\"");
} else {
log::debug!("Identity conversion failed again profile={id} err=\"{reason}\"");
}
}
}
}
@@ -516,6 +520,20 @@ async fn run_pass() -> bool {
deferred
}
static PASS_BROWSER: crate::log_streak::KeyedStreak =
crate::log_streak::KeyedStreak::new(module_path!(), "Headless browser for identity conversion");
/// Every pass retries a profile that failed, so its warning is said once.
fn first_failure_for(profile_id: &str) -> bool {
static WARNED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
let mut warned = WARNED.lock().unwrap_or_else(|e| e.into_inner());
if warned.iter().any(|known| known == profile_id) {
return false;
}
warned.push(profile_id.to_string());
true
}
pub fn request_conversion_pass() {
if cfg!(test) || PASS_REQUESTED.swap(true, Ordering::SeqCst) {
return;
+250 -217
View File
@@ -109,7 +109,7 @@ impl WayfernConfig {
match self.checked_launch_args().await {
Ok(args) => self.launch_args = args,
Err(error) => {
log::warn!("Dropped imported launch arguments: {error}");
log::warn!("Imported launch arguments dropped err=\"{error}\"");
self.launch_args.clear();
}
}
@@ -553,8 +553,8 @@ impl WayfernManager {
screen_claim_over_host(Some(device_json), host_screen_size(app_handle))
{
log::warn!(
"Profile {} claims a {claimed_w}x{claimed_h} screen on a {host_w}x{host_h} display: its window can never fill the screen it reports, which a page can measure",
profile.name
"Claimed screen larger than the display, a page can measure the gap profile={} claimed={claimed_w}x{claimed_h} display={host_w}x{host_h}",
profile.id
);
}
}
@@ -644,7 +644,7 @@ pub fn entitlement_cache_switch(version: &str, cache_root: &Path) -> Option<Stri
let dir = cache_root.join("wayfern-entitlements");
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!(
"Could not create the Wayfern entitlement cache at {}: {e}; the browser keeps its default",
"Entitlement cache dir create failed, browser keeps its default path={} err=\"{e}\"",
dir.display()
);
return None;
@@ -765,7 +765,7 @@ pub fn persona_switch(
let body = serde_json::to_vec(&crate::wayfern_persona::document(&fields)).ok()?;
if let Err(e) = std::fs::write(&path, body) {
log::warn!(
"Could not write the persona at {}: {e}; the browser offers no fill entries",
"Persona write failed, browser offers no fill entries path={} err=\"{e}\"",
path.display()
);
return None;
@@ -791,7 +791,7 @@ pub fn camera_switches(version: &str, config: &WayfernConfig) -> Vec<String> {
return Vec::new();
};
if !Path::new(file).is_file() {
log::warn!("Camera source {file} is missing; the claimed camera serves dark frames");
log::warn!("Camera source missing, camera serves dark frames file={file}");
return Vec::new();
}
let mut switches = vec![format!("--wayfern-camera-file={file}")];
@@ -804,9 +804,7 @@ pub fn camera_switches(version: &str, config: &WayfernConfig) -> Vec<String> {
if valid_camera_crop(crop) {
switches.push(format!("--wayfern-camera-crop={crop}"));
} else {
log::warn!(
"Camera crop {crop:?} is not x,y,width,height in source pixels; using the whole frame"
);
log::warn!("Camera crop is not x,y,width,height, using the whole frame crop={crop:?}");
}
}
switches
@@ -865,15 +863,13 @@ pub fn widevine_switch(version: &str, data_root: &Path) -> Option<String> {
let payload = dir.join("_platform_specific").join(platform).join(library);
if !payload.is_file() {
log::warn!(
"Widevine is provisioned at {} but {} is missing; the browser will register no CDM",
"Widevine library missing, browser registers no CDM dir={} missing={}",
dir.display(),
payload.display()
);
}
}
None => log::warn!(
"Widevine does not ship for this platform; the CDM directory is passed as provisioned"
),
None => log::warn!("Widevine does not ship for this platform, CDM dir passed as provisioned"),
}
Some(format!("--wayfern-widevine-cdm-dir={}", dir.display()))
}
@@ -938,19 +934,24 @@ impl BrowserLogTap {
/// Keep reading the browser's stderr for its lifetime, keeping only what it
/// says about Wayfern. Reading it all is what keeps the pipe from filling.
fn tap_browser_stderr(
stderr: tokio::process::ChildStderr,
tap: BrowserLogTap,
profile_name: String,
) {
/// `owner` is `key=value` text naming whose browser this is.
fn tap_browser_stderr(stderr: tokio::process::ChildStderr, tap: BrowserLogTap, owner: String) {
tauri::async_runtime::spawn(async move {
use tokio::io::{AsyncBufReadExt, BufReader};
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.contains("Wayfern") || line.contains("wayfern_") {
log::info!(
"[browser {profile_name}] {}",
crate::log_redaction::text(&line)
// What the browser did with the identity explains a profile that
// launched without one; the rest is detail.
let level = if line.contains("Wayfern launch identity") {
log::Level::Info
} else {
log::Level::Debug
};
log::log!(
level,
"Browser said {owner} line=\"{}\"",
crate::log_redaction::Plain(&crate::log_redaction::text(&line))
);
tap.push(line);
}
@@ -958,25 +959,39 @@ fn tap_browser_stderr(
});
}
/// The profile id inside a profile data path (`profiles/<uuid>/profile`, or an
/// ephemeral `<base>/<uuid>`), so a line names the profile and not the path.
fn profile_id_in_path(path: &str) -> String {
Path::new(path)
.components()
.rev()
.filter_map(|component| component.as_os_str().to_str())
.find(|component| uuid::Uuid::parse_str(component).is_ok())
.unwrap_or("unknown")
.to_string()
}
pub(crate) struct HeadlessWayfern {
child: tokio::process::Child,
user_data_dir: PathBuf,
page_ws_url: String,
label: String,
}
impl HeadlessWayfern {
/// `label` is `key=value` text saying what the browser is for.
pub(crate) async fn start(
manager: &WayfernManager,
profile: &BrowserProfile,
token: Option<&str>,
label: &str,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let started = std::time::Instant::now();
let executable_path = BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
let port = WayfernManager::find_free_port().await?;
log::info!("Launching headless Wayfern on port {port} for {label}");
let user_data_dir =
std::env::temp_dir().join(format!("wayfern_fingerprint_{}", uuid::Uuid::new_v4()));
@@ -1037,6 +1052,7 @@ impl HeadlessWayfern {
child,
user_data_dir,
page_ws_url: String::new(),
label: label.to_string(),
};
let targets = match manager
@@ -1053,6 +1069,12 @@ impl HeadlessWayfern {
.into_iter()
.find_map(|target| target.websocket_debugger_url)
.expect("CDP is ready only when a page has a socket");
log::info!(
"Headless browser started pid={} cdp_port={port} version={} {label} elapsed_ms={}",
session.child.id().unwrap_or_default(),
crate::log_redaction::Plain(&profile.version),
started.elapsed().as_millis()
);
Ok(session)
}
@@ -1074,13 +1096,22 @@ impl HeadlessWayfern {
pub(crate) async fn stop(mut self) {
if let Some(pid) = self.child.id() {
kill_browser_process(pid);
if tokio::time::timeout(Duration::from_secs(5), self.child.wait())
let how = if tokio::time::timeout(Duration::from_secs(5), self.child.wait())
.await
.is_err()
{
let _ = self.child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
}
match tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await {
Ok(_) => "killed",
Err(_) => "still_running",
}
} else {
"terminated"
};
log::info!(
"Headless browser stopped pid={pid} how={how} {}",
self.label
);
}
let _ = std::fs::remove_dir_all(&self.user_data_dir);
}
@@ -1103,10 +1134,10 @@ pub enum StopOutcome {
impl std::fmt::Display for StopOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Closed => "closed cleanly",
Self::Closed => "closed",
Self::Terminated => "terminated",
Self::Killed => "killed",
Self::StillRunning => "still running",
Self::StillRunning => "still_running",
})
}
}
@@ -1240,7 +1271,7 @@ impl ProbeTransport {
/// sidecar behind it.
async fn shutdown(self) {
if let Some(id) = self.donut_worker_id {
let _ = crate::proxy_runner::stop_proxy_process(&id).await;
let _ = crate::proxy_runner::stop_proxy_process_because(&id, "geo_probe_done").await;
}
if let Some(id) = self.xray_worker_id {
let _ = crate::xray_worker_runner::stop_xray_worker(&id).await;
@@ -1278,7 +1309,6 @@ pub struct WayfernLaunchResult {
}
struct WayfernInstance {
id: String,
process_id: Option<u32>,
profile_path: Option<String>,
url: Option<String>,
@@ -1833,17 +1863,20 @@ impl WayfernManager {
let max_attempts = 120;
let delay = Duration::from_millis(500);
let pid = child.id().unwrap_or_default();
let mut last_error: Option<String> = None;
for attempt in 0..max_attempts {
if let Ok(Some(status)) = child.try_wait() {
tokio::time::sleep(Duration::from_millis(300)).await;
let said = log.lines().join("\n");
log::error!("Wayfern exited on port {port} before CDP was ready ({status})");
log::warn!(
"Browser exited before CDP was ready pid={pid} cdp_port={port} status=\"{status}\""
);
return Err(early_exit_error(&said, &status).into());
}
match self.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
log::info!("CDP port {port} is open after {attempt} attempts");
log::debug!("CDP port open pid={pid} cdp_port={port} attempts={attempt}");
return self
.wait_for_page_targets_while_running(port, child, log)
.await;
@@ -1860,7 +1893,6 @@ impl WayfernManager {
}
let detail = last_error.unwrap_or_else(|| "no attempts completed".to_string());
log::error!("CDP not ready after {max_attempts} attempts on port {port}: {detail}");
Err(format!("CDP not ready after {max_attempts} attempts on port {port}: {detail}").into())
}
@@ -1873,9 +1905,13 @@ impl WayfernManager {
log: &BrowserLogTap,
) -> Result<Vec<CdpTarget>, Box<dyn std::error::Error + Send + Sync>> {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
let pid = child.id().unwrap_or_default();
loop {
if let Ok(Some(status)) = child.try_wait() {
tokio::time::sleep(Duration::from_millis(300)).await;
log::warn!(
"Browser exited before a page was ready pid={pid} cdp_port={port} status=\"{status}\""
);
return Err(early_exit_error(&log.lines().join("\n"), &status).into());
}
let detail = match self.get_cdp_targets(port).await {
@@ -2047,11 +2083,7 @@ impl WayfernManager {
json!([&locale_str, Self::base_language(&locale_str)]),
);
}
log::info!(
"Applied geolocation to Wayfern fingerprint: {} ({})",
geo.locale.as_string(),
geo.timezone
);
log::debug!("Geolocation applied to fingerprint");
true
}
Err(e) => {
@@ -2067,7 +2099,10 @@ impl WayfernManager {
// stamp `geo_proxy_signature`, so the launch-time refresh sees a
// signature mismatch and probes again through the local worker the
// browser is about to use.
log::warn!("Geolocation failed; leaving the fingerprint's location ungenerated: {e}");
log::warn!(
"Geolocation failed, fingerprint location left ungenerated err=\"{}\"",
crate::log_redaction::text(&e.to_string())
);
false
}
}
@@ -2276,16 +2311,15 @@ impl WayfernManager {
}
Err(e) => {
log::warn!(
"Could not start an Xray-core worker to carry the VLESS geolocation probe ({e}); skipping the probe rather than sending it unproxied"
"Geolocation probe skipped, Xray worker start failed err=\"{}\"",
crate::log_redaction::text(&e)
);
ProbeTransport::default()
}
}
}
ProbeRoute::Unroutable => {
log::warn!(
"No transport here can carry the geolocation probe through this profile's upstream; skipping the probe rather than resolving this machine's own address"
);
log::warn!("Geolocation probe skipped, no transport can carry it through the upstream");
ProbeTransport::default()
}
}
@@ -2314,7 +2348,8 @@ impl WayfernManager {
// real address. `None` means "no proxied probe available", and the
// caller skips the probe entirely rather than making it unproxied.
log::warn!(
"Could not start local proxy worker for geolocation ({e}); skipping the probe rather than sending it unproxied"
"Geolocation probe skipped, proxy worker start failed err=\"{}\"",
crate::log_redaction::text(&e.to_string())
);
ProbeTransport {
proxy: None,
@@ -2350,7 +2385,7 @@ impl WayfernManager {
self,
profile,
wayfern_token.as_deref(),
&format!("generation for {}", profile.name),
&format!("purpose=generation profile={}", profile.id),
)
.await?;
let ws_url = session.page_ws_url().to_string();
@@ -2450,7 +2485,8 @@ impl WayfernManager {
transport.proxy.is_some(),
) {
log::warn!(
"Skipping the geolocation probe: this profile has an upstream but no proxied probe could be built, and probing directly would write this machine's own location into the fingerprint"
"Geolocation probe skipped, profile has an upstream but no proxied probe profile={}",
profile.id
);
false
} else {
@@ -2479,11 +2515,10 @@ impl WayfernManager {
"WAYFERN_FINGERPRINT_GENERATION_FAILED",
config.os.as_deref(),
);
log::error!(
"Wayfern refused to {what} for profile {}: {} (browser said: {})",
crate::log_redaction::Plain(&profile.name),
coded,
cdp_error_message(&detail)
log::warn!(
"Fingerprint generation refused profile={} step=\"{what}\" err=\"{}\"",
profile.id,
crate::log_redaction::Plain(&cdp_error_message(&detail))
);
return Err(coded.into());
}
@@ -2494,30 +2529,26 @@ impl WayfernManager {
let fingerprint_json = serde_json::to_string(&fingerprint)
.map_err(|e| format!("Failed to serialize fingerprint: {e}"))?;
// Report the platform the engine actually produced alongside the one that
// was asked for. Logging only the request made this line useless for
// diagnosing a fingerprint that came back as something else.
// The produced platform beside the requested OS: a device that came back
// as something else is only visible when both are on the line.
log::info!(
"Generated Wayfern fingerprint for requested OS: {}, produced platform: {:?}, fields: {:?}",
os,
fingerprint.get("platform").and_then(|p| p.as_str()),
fingerprint
.as_object()
.map(|o| o.keys().collect::<Vec<_>>())
"Fingerprint generated profile={} os={} platform={} identity={} geolocation={}",
profile.id,
crate::log_redaction::Plain(os),
crate::log_redaction::Plain(
fingerprint
.get("platform")
.and_then(|p| p.as_str())
.unwrap_or("none")
),
identity_id.is_some(),
if geolocation_applied {
"applied"
} else {
"skipped"
}
);
// Log timezone/geolocation fields specifically for debugging
if let Some(obj) = fingerprint.as_object() {
log::info!(
"Generated fingerprint - timezone: {:?}, timezoneOffset: {:?}, latitude: {:?}, longitude: {:?}, language: {:?}",
obj.get("timezone"),
obj.get("timezoneOffset"),
obj.get("latitude"),
obj.get("longitude"),
obj.get("language")
);
}
if use_identity_api && identity_id.is_none() {
// Without the handle the stored fingerprint is not reproducible, which
// would leave a profile that silently changes device on every launch.
@@ -2552,8 +2583,8 @@ impl WayfernManager {
Ok(args) => args,
Err(error) => {
log::warn!(
"Launching profile {} without its custom launch arguments: {error}",
profile.name
"Custom launch arguments dropped profile={} err=\"{error}\"",
profile.id
);
let _ = crate::events::emit(
"profile-launch-args-skipped",
@@ -2570,7 +2601,6 @@ impl WayfernManager {
Some(p) => p,
None => Self::find_free_port().await?,
};
log::info!("Launching Wayfern on CDP port {port} (detached)");
// Diagnostic: verify critical profile files and test cookie decryption
{
@@ -2588,14 +2618,11 @@ impl WayfernManager {
}
};
if key_path.exists() {
// Length only. The contents are the profile's encryption key, and this
// log is the first thing a user attaches to a bug report.
let key_len = std::fs::metadata(&key_path).map(|m| m.len()).unwrap_or(0);
log::info!("Pre-launch: os_crypt_key present ({key_len} bytes)");
} else {
log::warn!("Pre-launch: os_crypt_key NOT FOUND");
}
// Length only. The contents are the profile's encryption key, and this
// log is the first thing a user attaches to a bug report.
let key_len = std::fs::metadata(&key_path).map(|m| m.len()).ok();
let mut cookie_counts = None;
let mut decryption = "not_checked";
if cookies_path.exists() {
// Try to open Cookies DB and check if encrypted cookies can be decrypted
@@ -2613,11 +2640,7 @@ impl WayfernManager {
let total_count: i64 = conn
.query_row("SELECT COUNT(*) FROM cookies", [], |r| r.get(0))
.unwrap_or(0);
log::info!(
"Pre-launch: Cookies DB has {} total cookies, {} encrypted",
total_count,
cookie_count
);
cookie_counts = Some((total_count, cookie_count));
// Try decrypting one cookie using the cookie_manager
if let Some(encryption_key) =
@@ -2628,7 +2651,6 @@ impl WayfernManager {
) {
if let Ok(mut rows) = stmt.query([]) {
if let Ok(Some(row)) = rows.next() {
let name: String = row.get(0).unwrap_or_default();
let host: String = row.get(1).unwrap_or_default();
let encrypted: Vec<u8> = row.get(2).unwrap_or_default();
let decrypted = crate::cookie_manager::chrome_decrypt::decrypt(
@@ -2636,25 +2658,26 @@ impl WayfernManager {
&host,
&encryption_key,
);
match decrypted {
Some(val) => log::info!(
"Pre-launch: Cookie decryption SUCCEEDED for '{}' (host: {}, decrypted {} bytes)",
name, host, val.len()
),
None => log::error!(
"Pre-launch: Cookie decryption FAILED for '{}' (host: {}, encrypted {} bytes)",
name, host, encrypted.len()
),
}
decryption = if decrypted.is_some() { "ok" } else { "failed" };
}
}
}
} else {
log::error!("Pre-launch: Failed to derive encryption key from os_crypt_key");
decryption = "no_key";
}
}
} else {
log::warn!("Pre-launch: Cookies NOT FOUND");
}
let (cookies, encrypted) = cookie_counts.unwrap_or_default();
log::debug!(
"Cookie store before launch profile={} os_crypt_key_bytes={key_len:?} cookies={cookies} encrypted={encrypted} decryption={decryption}",
profile.id
);
if encrypted > 0 && matches!(decryption, "failed" | "no_key") {
log::error!(
"Cookie decryption check failed profile={} encrypted={encrypted} reason={decryption}",
profile.id
);
}
}
@@ -2697,13 +2720,10 @@ impl WayfernManager {
let (fit_w, fit_h) = fit_window_to_work_area((w, h), work_area.map(|area| area.size));
let (x, y) = work_area.map_or((0, 0), |area| area.origin);
if (fit_w, fit_h) == (w, h) {
log::info!("Sizing Wayfern window to fingerprint dimensions: {w}x{h}");
} else {
log::info!(
"Sizing Wayfern window to {fit_w}x{fit_h}: the profile describes a {w}x{h} window, which does not fit this display's usable area"
);
}
log::debug!(
"Window size profile={} window={fit_w}x{fit_h} described={w}x{h}",
profile.id
);
args.push(format!("--window-size={fit_w},{fit_h}"));
args.push(format!("--window-position={x},{y}"));
}
@@ -2775,7 +2795,6 @@ impl WayfernManager {
// browser still works without the token (cross-OS fingerprinting just
// won't be enabled for this session, and the next launch will pick it
// up once the token arrives).
log::info!("Wayfern token not ready for paid user, waiting briefly...");
for _ in 0..3 {
tokio::time::sleep(Duration::from_secs(1)).await;
wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
@@ -2784,11 +2803,12 @@ impl WayfernManager {
}
}
if wayfern_token.is_none() {
log::warn!(
"Wayfern token still unavailable after wait; launching without it (api.donutbrowser.com may be unreachable)"
);
TOKEN_AT_LAUNCH.failed("not issued within 3s, launching without it");
}
}
if wayfern_token.is_some() {
TOKEN_AT_LAUNCH.succeeded();
}
// A cross-OS claim is authorized from the `wayfernToken` PARAMETER of
// setIdentity/setFingerprint, so with no token in hand the apply is refused
@@ -2813,10 +2833,6 @@ impl WayfernManager {
if let Some(claimed) = Self::claimed_operating_system(config, stored_device.as_ref()) {
let host_os = crate::profile::types::get_host_os();
if claimed != host_os.as_str() {
log::error!(
"Refusing to launch profile {}: it claims {claimed} on a {host_os} host and no Wayfern token is available",
profile.name
);
return Err(
crate::backend_error_with_detail("WAYFERN_CROSS_OS_REQUIRES_PLAN", claimed).into(),
);
@@ -2830,9 +2846,9 @@ impl WayfernManager {
.is_entitled_to_wayfern_token()
.await
{
log::error!(
"Refusing to launch profile {}: it stores fingerprint edits and no Wayfern token is available yet",
profile.name
log::warn!(
"Launch refused, fingerprint edits need a Wayfern token that is not available profile={}",
profile.id
);
return Err(crate::backend_error("WAYFERN_PLAN_CHECK_UNAVAILABLE").into());
}
@@ -2874,8 +2890,8 @@ impl WayfernManager {
&& config.fingerprint.is_none()
{
log::warn!(
"Profile {} has an identity but no resolved timezone (or no claimed operating system); applying it over CDP after launch instead of at startup",
profile.name
"Identity has no timezone or OS, applying it over CDP after launch profile={}",
profile.id
);
}
let identity_file = match &launch_identity {
@@ -2897,9 +2913,9 @@ impl WayfernManager {
) {
Ok(()) => true,
Err(reason) => {
log::info!(
"Session restore is off for profile {}: {reason}",
profile.name
log::debug!(
"Session restore off profile={} reason=\"{reason}\"",
profile.id
);
false
}
@@ -2917,18 +2933,18 @@ impl WayfernManager {
.filter(|value| WebRtcMode::parse(value).is_none())
{
log::warn!(
"Profile {} names an unknown WebRTC mode {unknown:?}; launching with auto",
profile.name
"Unknown WebRTC mode, launching with auto profile={} mode={unknown:?}",
profile.id
);
}
let exit_ip = crate::fingerprint_consistency::cached_exit_ip(profile);
let webrtc = webrtc_switches(&profile.version, webrtc_mode, exit_ip.as_deref());
if !webrtc.is_empty() {
log::info!(
"WebRTC for profile {}: mode {}, exit IP {}",
profile.name,
log::debug!(
"WebRTC profile={} mode={} exit_ip_known={}",
profile.id,
webrtc_mode.switch_value(),
exit_ip.as_deref().unwrap_or("unknown")
exit_ip.is_some()
);
}
args.extend(webrtc);
@@ -2957,13 +2973,6 @@ impl WayfernManager {
&profile.version,
&crate::app_dirs::data_dir(),
));
if let Some(path) = &identity_file {
log::info!(
"Launch identity for profile {} written to {}",
profile.name,
path.display()
);
}
apply_launch_args(&mut args, &custom_launch_args);
let mut command = TokioCommand::new(&executable_path);
@@ -2974,7 +2983,6 @@ impl WayfernManager {
.stderr(Stdio::piped());
if let Some(ref token) = wayfern_token {
command.env("WAYFERN_TOKEN", token);
log::info!("Wayfern authorization configured for browser process");
}
let mut child = command
@@ -2991,7 +2999,15 @@ impl WayfernManager {
let process_id = child.id();
let log_tap = BrowserLogTap::default();
if let Some(stderr) = child.stderr.take() {
tap_browser_stderr(stderr, log_tap.clone(), profile.name.clone());
tap_browser_stderr(
stderr,
log_tap.clone(),
format!(
"profile={} pid={}",
profile.id,
process_id.unwrap_or_default()
),
);
}
let page_targets = match self
@@ -3001,6 +3017,12 @@ impl WayfernManager {
Ok(targets) => targets,
Err(e) => {
if let Some(pid) = process_id {
if crate::proxy_storage::is_process_running(pid) {
log::warn!(
"Browser killed during launch profile={} pid={pid} reason=cdp_not_ready",
profile.id
);
}
kill_browser_process(pid);
}
return Err(e);
@@ -3008,7 +3030,11 @@ impl WayfernManager {
};
drop(child);
log::info!("Found {} page targets", page_targets.len());
log::debug!(
"Page targets ready profile={} pid={process_id:?} targets={}",
profile.id,
page_targets.len()
);
// An identity-backed profile: the id, the user's overrides and the exit's
// location are all the browser needs, and all the profile stores. The
@@ -3058,18 +3084,18 @@ impl WayfernManager {
.is_entitled_to_wayfern_token()
.await
{
log::error!(
"Killing Wayfern (pid {process_id:?}) for profile {}: the browser dropped this profile's fingerprint edits",
profile.name
log::warn!(
"Browser killed during launch profile={} pid={process_id:?} reason=fingerprint_edits_dropped",
profile.id
);
if let Some(pid) = process_id {
kill_browser_process(pid);
}
return Err(crate::backend_error("WAYFERN_PLAN_CHECK_UNAVAILABLE").into());
}
log::info!(
"Launch identity confirmed for profile {}: {confirmation}",
profile.name
log::debug!(
"Launch identity confirmed profile={} how=\"{confirmation}\"",
profile.id
);
// The only place an identity-backed profile's screen is known: the
// device is derived by the browser, so nothing on disk carries it.
@@ -3082,9 +3108,9 @@ impl WayfernManager {
}
}
Err(reason) => {
log::error!(
"Killing Wayfern (pid {process_id:?}) for profile {}: the launch identity was not applied: {reason}",
profile.name
log::warn!(
"Browser killed during launch profile={} pid={process_id:?} reason=identity_not_applied",
profile.id
);
if let Some(pid) = process_id {
kill_browser_process(pid);
@@ -3144,10 +3170,10 @@ impl WayfernManager {
if let Some(ref token) = wayfern_token {
params.insert("wayfernToken".to_string(), json!(token));
}
log::info!(
"Applying Wayfern identity {} with {} override(s): {:?}",
log::debug!(
"Applying identity profile={} identity={} overrides={:?}",
profile.id,
identity_id,
overrides.len(),
overrides.keys().collect::<Vec<_>>()
);
@@ -3165,10 +3191,12 @@ impl WayfernManager {
{
Ok(_) => {
applied_ok = true;
log::info!("Successfully applied identity to page target");
}
Err(e) => {
log::error!("Failed to apply identity to target: {e}");
log::warn!(
"Identity apply to a page failed profile={} err=\"{e}\"",
profile.id
);
last_apply_error = Some(e.to_string());
}
}
@@ -3177,9 +3205,9 @@ impl WayfernManager {
if !applied_ok {
let detail = last_apply_error
.unwrap_or_else(|| "the browser exposed no page target to apply it to".to_string());
log::error!(
"Killing Wayfern (pid {process_id:?}) for profile {}: the identity was never applied: {detail}",
profile.name
log::warn!(
"Browser killed during launch profile={} pid={process_id:?} reason=identity_not_applied",
profile.id
);
if let Some(pid) = process_id {
kill_browser_process(pid);
@@ -3189,10 +3217,6 @@ impl WayfernManager {
);
}
} else if let Some(fingerprint_json) = &config.fingerprint {
log::info!(
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
fingerprint_json.len()
);
Self::warn_on_screen_over_host(fingerprint_json, profile, _app_handle);
// Both stored shapes, the bare object and the legacy
@@ -3202,26 +3226,6 @@ impl WayfernManager {
// gate reports to the user.
let fingerprint_for_cdp = Self::launch_fingerprint_payload(fingerprint_json)?;
log::info!(
"Fingerprint prepared for CDP command, fields: {:?}",
fingerprint_for_cdp
.as_object()
.map(|o| o.keys().collect::<Vec<_>>())
);
// Log timezone and geolocation fields specifically for debugging
if let Some(obj) = fingerprint_for_cdp.as_object() {
log::info!(
"Timezone/Geolocation fields - timezone: {:?}, timezoneOffset: {:?}, latitude: {:?}, longitude: {:?}, language: {:?}, languages: {:?}",
obj.get("timezone"),
obj.get("timezoneOffset"),
obj.get("latitude"),
obj.get("longitude"),
obj.get("language"),
obj.get("languages")
);
}
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
@@ -3244,17 +3248,18 @@ impl WayfernManager {
for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url {
log::info!("Applying fingerprint to page target");
match self
.send_cdp_command(ws_url, "Wayfern.setFingerprint", apply_params.clone())
.await
{
Ok(_) => {
applied_ok = true;
log::info!("Successfully applied fingerprint to page target");
}
Err(e) => {
log::error!("Failed to apply fingerprint to target: {e}");
log::warn!(
"Fingerprint apply to a page failed profile={} err=\"{e}\"",
profile.id
);
last_apply_error = Some(e.to_string());
}
}
@@ -3268,9 +3273,9 @@ impl WayfernManager {
// does not know about.
let detail = last_apply_error
.unwrap_or_else(|| "the browser exposed no page target to apply it to".to_string());
log::error!(
"Killing Wayfern (pid {process_id:?}) for profile {}: the fingerprint was never applied: {detail}",
profile.name
log::warn!(
"Browser killed during launch profile={} pid={process_id:?} reason=fingerprint_not_applied",
profile.id
);
if let Some(pid) = process_id {
kill_browser_process(pid);
@@ -3284,7 +3289,10 @@ impl WayfernManager {
);
}
} else {
log::warn!("No fingerprint found in config, browser will use default fingerprint");
log::warn!(
"No fingerprint stored, browser uses its default device profile={}",
profile.id
);
}
// Geolocation is handled internally by the browser binary.
@@ -3293,20 +3301,24 @@ impl WayfernManager {
if restore_session {
// The tabs the browser reopened are the user's; the URL gets a tab of
// its own instead of replacing whichever one came first.
log::info!("Opening the launch URL in a new tab beside the restored session");
if let Err(e) = self.open_url_on_port(port, url).await {
log::error!("Failed to open the launch URL in a new tab: {e}");
log::warn!(
"Launch URL tab open failed profile={} err=\"{}\"",
profile.id,
crate::log_redaction::text(&e.to_string())
);
}
} else {
log::info!("Navigating to URL via CDP");
if let Some(target) = page_targets.first() {
if let Some(ws_url) = &target.websocket_debugger_url {
if let Err(e) = self
.send_cdp_command(ws_url, "Page.navigate", json!({ "url": url }))
.await
{
log::error!("Failed to navigate to URL: {e}");
}
} else if let Some(target) = page_targets.first() {
if let Some(ws_url) = &target.websocket_debugger_url {
if let Err(e) = self
.send_cdp_command(ws_url, "Page.navigate", json!({ "url": url }))
.await
{
log::warn!(
"Launch URL navigation failed profile={} err=\"{}\"",
profile.id,
crate::log_redaction::text(&e.to_string())
);
}
}
}
@@ -3336,7 +3348,6 @@ impl WayfernManager {
let id = uuid::Uuid::new_v4().to_string();
let instance = WayfernInstance {
id: id.clone(),
process_id,
profile_path: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()),
@@ -3368,10 +3379,20 @@ impl WayfernManager {
};
if let Some(instance) = instance {
log::info!("Cleaning up Wayfern instance {}", instance.id);
if let Some(pid) = instance.process_id {
let started = std::time::Instant::now();
let outcome = self.stop_browser_process(pid, instance.cdp_port).await;
log::info!("Stopped Wayfern instance {id} (PID: {pid}): {outcome}");
let profile = profile_id_in_path(instance.profile_path.as_deref().unwrap_or_default());
let elapsed_ms = started.elapsed().as_millis();
if outcome == StopOutcome::StillRunning {
log::warn!(
"Browser stop failed, still running profile={profile} pid={pid} elapsed_ms={elapsed_ms}"
);
} else {
log::info!(
"Browser stopped profile={profile} pid={pid} how={outcome} elapsed_ms={elapsed_ms}"
);
}
}
}
@@ -3389,19 +3410,17 @@ impl WayfernManager {
if let Some(port) = cdp_port {
match tokio::time::timeout(Duration::from_secs(3), self.browser_close(port)).await {
Ok(Ok(())) => {}
Ok(Err(e)) => log::warn!("Browser.close was not accepted on port {port}: {e}"),
Err(_) => log::warn!("Browser.close timed out on port {port}"),
Ok(Err(e)) => log::debug!("Browser.close refused pid={pid} cdp_port={port} err=\"{e}\""),
Err(_) => log::debug!("Browser.close timed out pid={pid} cdp_port={port}"),
}
if wait_for_exit(pid, Duration::from_secs(5)).await {
return StopOutcome::Closed;
}
log::warn!("Wayfern (PID {pid}) did not exit after Browser.close; terminating it");
}
terminate_process(pid);
if wait_for_exit(pid, Duration::from_secs(5)).await {
return StopOutcome::Terminated;
}
log::warn!("Wayfern (PID {pid}) ignored the termination request; killing it");
force_kill_process(pid);
if wait_for_exit(pid, Duration::from_secs(2)).await {
return StopOutcome::Killed;
@@ -3485,7 +3504,7 @@ impl WayfernManager {
return Err(format!("CDP /json/new returned HTTP {}", resp.status()).into());
}
log::info!("Opened URL in new tab via CDP");
log::debug!("Opened URL in new tab cdp_port={port}");
Ok(())
}
@@ -3574,9 +3593,8 @@ impl WayfernManager {
});
} else {
log::info!(
"Wayfern process {} for profile {} is no longer running, cleaning up",
pid,
profile_path
"Browser exited profile={} pid={pid}",
profile_id_in_path(profile_path)
);
inner.instances.remove(&id);
return None;
@@ -3590,16 +3608,29 @@ impl WayfernManager {
if let Some((pid, found_profile_path, cdp_port)) =
Self::find_wayfern_process_by_profile(&target_path)
{
log::info!(
"Found running Wayfern process (PID: {}) for profile path via system scan",
pid
// A browser seconds old is almost always one this app is still
// launching: its instance is registered only once the launch finishes.
let age_s = crate::proxy_storage::process_start_time(pid).map(|start| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|now| now.as_secs().saturating_sub(start))
.unwrap_or_default()
});
let level = if age_s.is_some_and(|age| age < 120) {
log::Level::Debug
} else {
log::Level::Info
};
log::log!(
level,
"Browser adopted from a running process profile={} pid={pid} cdp_port={cdp_port:?} age_s={age_s:?}",
profile_id_in_path(&found_profile_path)
);
let instance_id = format!("recovered_{}", pid);
inner.instances.insert(
instance_id.clone(),
WayfernInstance {
id: instance_id.clone(),
process_id: Some(pid),
profile_path: Some(found_profile_path.clone()),
url: None,
@@ -3700,7 +3731,6 @@ impl WayfernManager {
std::fs::create_dir_all(&profile_path)?;
if let Some(existing) = self.find_wayfern_by_profile(&profile_path_str).await {
log::info!("Stopping existing Wayfern instance for profile");
self.stop_wayfern(&existing.id).await?;
}
@@ -3742,7 +3772,7 @@ impl WayfernManager {
}
for id in dead_ids {
log::info!("Cleaning up dead Wayfern instance: {id}");
log::debug!("Removed dead browser instance id={id}");
inner.instances.remove(&id);
}
}
@@ -3761,6 +3791,9 @@ fn kill_browser_process(pid: u32) {
force_kill_process(pid);
}
static TOKEN_AT_LAUNCH: crate::log_streak::Streak =
crate::log_streak::Streak::new(module_path!(), "Wayfern token at launch");
static WAYFERN_MANAGER: std::sync::LazyLock<WayfernManager> =
std::sync::LazyLock::new(WayfernManager::new);
+6 -8
View File
@@ -130,12 +130,6 @@ impl WayfernTermsManager {
"No Wayfern browser downloaded. Please download a Wayfern browser version first.".to_string()
})?;
log::info!(
"Running Wayfern with {} flag: {:?}",
ACCEPT_TERMS_FLAG,
executable_path
);
#[cfg(target_os = "macos")]
{
// On macOS, if it's an app bundle, we need to find the actual executable
@@ -169,7 +163,11 @@ impl WayfernTermsManager {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::error!("Wayfern terms acceptance failed: {stderr}");
log::error!(
"Wayfern terms acceptance failed status=\"{}\" stderr={:?}",
output.status,
crate::log_redaction::text(stderr.trim())
);
return Err(format!(
"Wayfern terms acceptance failed with exit code: {:?}",
output.status.code()
@@ -183,7 +181,7 @@ impl WayfernTermsManager {
);
}
log::info!("Wayfern terms and conditions accepted successfully");
log::info!("Wayfern terms accepted");
// The frontend only re-reads the marker when it drove the acceptance
// itself. Anything else that accepts (the REST API, a WebDriver session)
// would leave the blocking dialog open, so the change is announced.
+9 -9
View File
@@ -50,7 +50,7 @@ pub struct WindowDecorations {
pub fn use_client_side_decorations() -> bool {
if let Ok(value) = std::env::var("DONUT_LINUX_CLIENT_DECORATIONS") {
let forced = matches!(value.trim(), "1" | "true" | "yes");
log::info!("Client-side decorations forced to {forced} by DONUT_LINUX_CLIENT_DECORATIONS");
log::info!("Client-side decorations forced={forced} by=DONUT_LINUX_CLIENT_DECORATIONS");
return forced;
}
@@ -86,8 +86,8 @@ pub fn use_client_side_decorations() -> bool {
if on_kde && on_wayland {
log::info!(
"Keeping server-side decorations: KWin on Wayland may draw its own titlebar over the \
app's. Set DONUT_LINUX_CLIENT_DECORATIONS=1 to override."
"Server-side decorations kept: KWin on Wayland draws its own titlebar \
(DONUT_LINUX_CLIENT_DECORATIONS=1 overrides)"
);
return false;
}
@@ -121,14 +121,14 @@ mod imp {
use tauri::Emitter;
let Some(settings) = gtk::Settings::default() else {
log::warn!("No GTK settings available; using the default decoration layout");
log::warn!("GTK settings unavailable; default decoration layout used");
return;
};
store(settings.gtk_decoration_layout().map(|v| v.to_string()));
log::info!(
"Window decoration layout: {}",
cached().as_deref().unwrap_or("<unset>")
"Window decoration layout=\"{}\"",
cached().as_deref().unwrap_or("-")
);
// The user can rearrange titlebar buttons while the app is running, and
@@ -139,12 +139,12 @@ mod imp {
settings.connect_gtk_decoration_layout_notify(move |settings| {
let layout = settings.gtk_decoration_layout().map(|v| v.to_string());
log::info!(
"Window decoration layout changed to: {}",
layout.as_deref().unwrap_or("<unset>")
"Window decoration layout changed layout=\"{}\"",
layout.as_deref().unwrap_or("-")
);
store(layout.clone());
if let Err(e) = handle.emit("window-decoration-layout-changed", layout) {
log::warn!("Failed to emit window decoration layout change: {e}");
log::warn!("Event emit failed event=window-decoration-layout-changed err=\"{e}\"");
}
});
}
+49 -16
View File
@@ -138,9 +138,14 @@ async fn wait_until_ready(
if let Some(config) = get_xray_worker_config(id) {
if !process_identity_matches(supervisor_pid, Some(supervisor_start_time)) {
let log = std::fs::read_to_string(xray_worker_log_path(id)).unwrap_or_default();
if !log.is_empty() {
log::error!("Xray worker {id} exited during startup: {log}");
}
let tail: String = {
let chars: Vec<char> = log.trim().chars().collect();
chars[chars.len().saturating_sub(1500)..].iter().collect()
};
log::warn!(
"Xray worker exited during startup id={id} pid={supervisor_pid} log=\"{}\"",
crate::log_redaction::Plain(&crate::log_redaction::text(&tail))
);
return Err(structured_error("XRAY_START_FAILED"));
}
if config.pid == Some(supervisor_pid)
@@ -226,11 +231,7 @@ pub async fn start_xray_worker_for_owner(
match spawn_xray_worker(profile_id, vless_uri, owner_pid, owner_start_time).await {
Ok(worker) => return Ok(worker),
Err(error) => {
if attempt < 3 {
log::warn!(
"Xray worker startup attempt {attempt} failed; retrying with a new local port"
);
}
log::debug!("Xray worker start attempt failed attempt={attempt}/3 err=\"{error}\"");
last_error = Some(error.to_string());
}
}
@@ -248,6 +249,7 @@ async fn spawn_xray_worker(
owner_pid: u32,
owner_start_time: u64,
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
let started = std::time::Instant::now();
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let local_port = listener
@@ -332,6 +334,15 @@ async fn spawn_xray_worker(
let ready = wait_until_ready(&id, supervisor_pid, supervisor_start_time).await?;
pending.armed = false;
log::info!(
"Xray worker started id={id} pid={supervisor_pid} xray_pid={} port={local_port} profile={} elapsed_ms={}",
ready
.xray_pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "none".to_string()),
profile_id.unwrap_or("none"),
started.elapsed().as_millis()
);
Ok(ready)
}
@@ -340,7 +351,9 @@ pub fn set_browser_pid(worker_id: &str, browser_pid: u32) -> bool {
return false;
}
let Some(browser_pid_start_time) = resolve_process_start_time(browser_pid) else {
log::warn!("Failed to resolve browser PID {browser_pid} identity for Xray worker {worker_id}");
log::warn!(
"Browser start time unknown, Xray worker owner not recorded xray_worker={worker_id} browser_pid={browser_pid}"
);
return false;
};
let Some(mut config) = get_xray_worker_config(worker_id) else {
@@ -358,7 +371,7 @@ fn persist_browser_identity(
config.browser_pid_start_time = Some(browser_pid_start_time);
if !crate::xray_worker_storage::update_xray_worker_config(config) {
log::warn!(
"Failed to persist browser PID {browser_pid} on Xray worker {}",
"Xray worker owner write failed xray_worker={} browser_pid={browser_pid}",
config.id
);
return false;
@@ -392,6 +405,10 @@ impl Drop for PendingSupervisor {
if let Some(pid) = self.pid {
if self.pid_start_time.is_none() || process_identity_matches(pid, self.pid_start_time) {
terminate_supervisor_unchecked(pid);
log::info!(
"Xray worker stopped id={} pid={pid} reason=start_failed",
self.id
);
}
}
if let Some((pid, start_time)) = xray_process {
@@ -457,19 +474,35 @@ pub fn stop_xray_worker_now(id: &str) -> Result<bool, Box<dyn std::error::Error>
};
delete_xray_worker_config(id);
if let Some(pid) = config
let supervisor = config
.pid
.filter(|pid| process_identity_matches(*pid, config.pid_start_time))
{
.filter(|pid| process_identity_matches(*pid, config.pid_start_time));
if let Some(pid) = supervisor {
terminate_supervisor_unchecked(pid);
}
if let Some(pid) = config
let xray = config
.xray_pid
.filter(|pid| process_identity_matches(*pid, config.xray_pid_start_time))
{
.filter(|pid| process_identity_matches(*pid, config.xray_pid_start_time));
if let Some(pid) = xray {
terminate_process_unchecked(pid);
}
delete_xray_worker_config(id);
let pid_text = |pid: Option<u32>| {
pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "none".to_string())
};
log::info!(
"Xray worker stopped id={id} pid={} xray_pid={} profile={} how={}",
pid_text(config.pid),
pid_text(config.xray_pid),
config.profile_id.as_deref().unwrap_or("none"),
if supervisor.is_some() || xray.is_some() {
"terminated"
} else {
"already_gone"
}
);
Ok(true)
}
+31 -24
View File
@@ -14,7 +14,10 @@ import {
NoteAction,
useTimeFormat,
} from "@/components/agent-console-parts";
import { DATA_TABLE_CLASSES } from "@/components/table-controls";
import {
DATA_TABLE_CLASSES,
TableCheckboxSlot,
} from "@/components/table-controls";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -190,17 +193,19 @@ export function AgentProfiles({
<TableHeader className={DATA_TABLE_CLASSES.header}>
<TableRow className={DATA_TABLE_CLASSES.headerRow}>
<TableHead className={cn(DATA_TABLE_CLASSES.head, "w-8")}>
<Checkbox
checked={allChecked}
aria-label={t("common.aria.selectAll")}
onCheckedChange={(checked) => {
setSelected(
checked === true
? new Set(rows.map((row) => row.profile_id))
: new Set(),
);
}}
/>
<TableCheckboxSlot>
<Checkbox
checked={allChecked}
aria-label={t("common.aria.selectAll")}
onCheckedChange={(checked) => {
setSelected(
checked === true
? new Set(rows.map((row) => row.profile_id))
: new Set(),
);
}}
/>
</TableCheckboxSlot>
</TableHead>
<TableHead className={DATA_TABLE_CLASSES.head}>
{t("agent.profiles.columns.profile")}
@@ -259,18 +264,20 @@ export function AgentProfiles({
}
>
<TableCell>
<Checkbox
checked={selected.has(row.profile_id)}
aria-label={t("agent.profiles.selectRow", { name })}
onCheckedChange={(checked) => {
setSelected((previous) => {
const next = new Set(previous);
if (checked === true) next.add(row.profile_id);
else next.delete(row.profile_id);
return next;
});
}}
/>
<TableCheckboxSlot>
<Checkbox
checked={selected.has(row.profile_id)}
aria-label={t("agent.profiles.selectRow", { name })}
onCheckedChange={(checked) => {
setSelected((previous) => {
const next = new Set(previous);
if (checked === true) next.add(row.profile_id);
else next.delete(row.profile_id);
return next;
});
}}
/>
</TableCheckboxSlot>
</TableCell>
<TableCell className="max-w-56">
<span className="block truncate text-sm font-medium">
+1 -1
View File
@@ -2565,7 +2565,7 @@ export function ProfilesDataTable({
sideOffset={4}
horizontalOffset={8}
>
<span className="inline-flex items-center">
<span className="block">
<TableRowCheckbox
table={table}
row={row}
+68 -58
View File
@@ -549,6 +549,12 @@ export function SortableColumnHeader<T extends RowData>({
const selectionAnchors = new WeakMap<object, string>();
/** Centers a selection checkbox in its cell, at any column width. Inline, it
* would sit on the text baseline and hug the cell's start. */
export function TableCheckboxSlot({ children }: { children: React.ReactNode }) {
return <span className="flex items-center justify-center">{children}</span>;
}
export function TableSelectHeader<T extends RowData>({
table,
}: {
@@ -560,26 +566,28 @@ export function TableSelectHeader<T extends RowData>({
.rows.filter((row) => row.getCanSelect());
const selected = selectable.filter((row) => row.getIsSelected()).length;
return (
<Checkbox
aria-label={t("common.aria.selectAll")}
className="relative after:absolute after:-inset-2 after:content-['']"
checked={
selected > 0 && selected === selectable.length
? true
: selected > 0
? "indeterminate"
: false
}
disabled={!selectable.length}
onCheckedChange={(checked) => {
selectionAnchors.delete(table);
table.setRowSelection(
checked
? Object.fromEntries(selectable.map((row) => [row.id, true]))
: {},
);
}}
/>
<TableCheckboxSlot>
<Checkbox
aria-label={t("common.aria.selectAll")}
className="relative after:absolute after:-inset-2 after:content-['']"
checked={
selected > 0 && selected === selectable.length
? true
: selected > 0
? "indeterminate"
: false
}
disabled={!selectable.length}
onCheckedChange={(checked) => {
selectionAnchors.delete(table);
table.setRowSelection(
checked
? Object.fromEntries(selectable.map((row) => [row.id, true]))
: {},
);
}}
/>
</TableCheckboxSlot>
);
}
@@ -595,45 +603,47 @@ export function TableRowCheckbox<T extends RowData>({
const { t } = useTranslation();
const hasSelection = table.getSelectedRowModel().rows.length > 0;
return (
<span className="group relative inline-flex size-4 items-center justify-center">
<Checkbox
aria-label={t("common.aria.selectRow")}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
className={cn(
"relative after:absolute after:-inset-2 after:content-['']",
icon &&
!hasSelection &&
"opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
<TableCheckboxSlot>
<span className="group relative flex size-4 shrink-0 items-center justify-center">
<Checkbox
aria-label={t("common.aria.selectRow")}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
className={cn(
"relative after:absolute after:-inset-2 after:content-['']",
icon &&
!hasSelection &&
"opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
)}
onClick={(event) => {
if (!row.getCanSelect()) return;
const ids = table
.getRowModel()
.rows.filter((item) => item.getCanSelect())
.map((item) => item.id);
table.setRowSelection((prev) =>
selectTableRange(
ids,
prev,
selectionAnchors.get(table) ?? null,
row.id,
!row.getIsSelected(),
event.shiftKey,
),
);
if (!event.shiftKey) selectionAnchors.set(table, row.id);
}}
/>
{icon && !hasSelection && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 group-hover:opacity-0 group-focus-within:opacity-0"
>
{icon}
</span>
)}
onClick={(event) => {
if (!row.getCanSelect()) return;
const ids = table
.getRowModel()
.rows.filter((item) => item.getCanSelect())
.map((item) => item.id);
table.setRowSelection((prev) =>
selectTableRange(
ids,
prev,
selectionAnchors.get(table) ?? null,
row.id,
!row.getIsSelected(),
event.shiftKey,
),
);
if (!event.shiftKey) selectionAnchors.set(table, row.id);
}}
/>
{icon && !hasSelection && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 group-hover:opacity-0 group-focus-within:opacity-0"
>
{icon}
</span>
)}
</span>
</span>
</TableCheckboxSlot>
);
}
+21 -1
View File
@@ -1,12 +1,32 @@
import { attachConsole } from "@tauri-apps/plugin-log";
import { attachConsole, error as logError } from "@tauri-apps/plugin-log";
let consoleAttached = false;
let errorsForwarded = false;
function describe(reason: unknown): string {
if (reason instanceof Error) return reason.stack ?? reason.message;
return typeof reason === "string" ? reason : JSON.stringify(reason);
}
export async function setupLogging() {
if (consoleAttached) {
return;
}
// Only uncaught failures go to the log file. Forwarding console.error too
// would loop: attachConsole writes every backend line back to the console.
if (!errorsForwarded) {
errorsForwarded = true;
window.addEventListener("error", (event) => {
void logError(
`Uncaught error err="${describe(event.error ?? event.message)}" source=${event.filename}:${event.lineno}:${event.colno}`,
);
});
window.addEventListener("unhandledrejection", (event) => {
void logError(`Unhandled rejection err="${describe(event.reason)}"`);
});
}
try {
await attachConsole();
consoleAttached = true;