mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 14:07:19 +02:00
refactor: better logs
This commit is contained in:
1 parent
2604994507
commit
bf625fac2c
91 files changed
+4353
-3239
No files matched your search
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;`,
|
||||
|
||||
@@ -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
@@ -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"]`,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
+377
-304
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
+278
-375
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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}\""
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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}\""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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()), "[]");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}\""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}\"");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}\"");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+200
-329
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
|
||||
@@ -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}\"");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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", ());
|
||||
|
||||
@@ -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(¤t);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
¤t,
|
||||
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
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}\"");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user