refactor: cleanup

This commit is contained in:
zhom committed 2026-09-22 01:17:33 +04:00
1 parent 7962e97474
commit f05aaa2cf2
31 files changed
+432 -2305

No files matched your search

+12 -4
View File
@@ -1299,6 +1299,7 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
StatusCode::PAYMENT_REQUIRED
} else if code == "WAYFERN_INSTANCE_LIMIT_REACHED"
|| code == "WAYFERN_GENERATION_LIMIT_REACHED"
|| code == "PROFILE_GENERATION_LIMIT_REACHED"
{
StatusCode::TOO_MANY_REQUESTS
} else if code == "WAYFERN_PLAN_CHECK_UNAVAILABLE"
@@ -1438,6 +1439,8 @@ async fn get_profile(
/// `"linux"`, `"android"`, `"ios"`). Omit it to match the host. Any other
/// OS is cross-OS spoofing and needs an active Pro plan; 402 otherwise.
/// A `wayfern_config` that fails to parse is a 400, never a silent default.
/// - Pro accounts may generate 100 profiles per rolling hour; the 101st is
/// refused with 429 and a `retryAfterSeconds` in the body.
#[utoipa::path(
post,
path = "/v1/profiles",
@@ -1447,6 +1450,7 @@ async fn get_profile(
(status = 400, description = "Invalid browser, invalid wayfern_config, or no downloaded version available"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Selected proxy requires payment, or a cross-OS fingerprint requires Pro"),
(status = 429, description = "Hourly profile generation limit reached; the body carries retryAfterSeconds"),
(status = 500, description = "Internal server error")
),
security(
@@ -1602,10 +1606,14 @@ async fn create_profile(
profile: ApiProfile::from(&profile),
}))
}
Err(e) => Err((
StatusCode::BAD_REQUEST,
format!("Failed to create profile: {e}"),
)),
Err(e) => {
let (status, body) = manager_error_response(e);
if status == StatusCode::TOO_MANY_REQUESTS {
let _ = crate::events::emit_empty("profile-generation-limit-reached");
}
log::warn!("[api] Could not create profile '{}': {body}", request.name);
Err((status, body))
}
}
}
+8 -271
View File
@@ -61,121 +61,10 @@ fn log_dir_for(root: Option<PathBuf>, portable: Option<&PathBuf>) -> Option<Path
/// File name `tauri-plugin-window-state` persists geometry under.
pub const WINDOW_STATE_FILENAME: &str = ".window-state.json";
/// File name of the pointer that records a data directory the user chose in
/// Settings.
pub const DATA_ROOT_POINTER_FILENAME: &str = "data-root.json";
static CUSTOM_DATA_ROOT: OnceLock<Option<PathBuf>> = OnceLock::new();
/// Where the pointer to a user-chosen data directory lives.
///
/// It must never sit inside `data_dir()` itself: a move deletes the old
/// directory once the copy verifies, which would take the pointer with it and
/// send the next start back to the platform default. Every branch below
/// therefore resolves OUTSIDE the data directory it points at.
///
/// - With `DONUTBROWSER_DATA_ROOT` set, `<root>/data-root.json`, a sibling of
/// `<root>/data`. An isolated run (the E2E harness) then keeps its own
/// pointer and can never read, or write, the real machine's.
/// - In portable mode, `<exe dir>/data-root.json`, beside `<exe dir>/data`, so
/// the choice travels with the install.
/// - Otherwise the platform preference directory, which is a different root
/// from `data_local_dir` on macOS, Linux and Windows alike.
pub fn data_root_pointer_file() -> PathBuf {
data_root_pointer_file_for(
data_root(),
portable_dir(),
base_dirs().preference_dir().join(app_name()),
)
}
/// Split out from `data_root_pointer_file` so the precedence is testable
/// without a `.portable` marker or process-wide environment mutation.
fn data_root_pointer_file_for(
root: Option<PathBuf>,
portable: Option<&PathBuf>,
preference_dir: PathBuf,
) -> PathBuf {
if let Some(root) = root {
return root.join(DATA_ROOT_POINTER_FILENAME);
}
if let Some(dir) = portable {
return dir.join(DATA_ROOT_POINTER_FILENAME);
}
preference_dir.join(DATA_ROOT_POINTER_FILENAME)
}
/// Read a pointer file written by a previous "move data directory".
///
/// A missing, unreadable, malformed, empty or relative entry resolves to
/// `None`. Falling back to the platform default is always better than
/// resolving every profile, binary and setting to a path that cannot exist.
pub fn read_data_root_pointer(file: &std::path::Path) -> Option<PathBuf> {
let content = std::fs::read_to_string(file).ok()?;
let parsed: serde_json::Value = match serde_json::from_str(&content) {
Ok(value) => value,
Err(e) => {
log::warn!(
"Ignoring the data directory pointer at {}: it is not valid JSON ({e})",
file.display()
);
return None;
}
};
let path = PathBuf::from(parsed.get("path")?.as_str()?);
if path.as_os_str().is_empty() || !path.is_absolute() {
log::warn!(
"Ignoring the data directory pointer at {}: {} is not an absolute path",
file.display(),
path.display()
);
return None;
}
Some(path)
}
/// Record a data directory for the next start. Written atomically, because a
/// truncated pointer read at startup would silently drop the user back onto
/// the platform default with an empty profile list.
pub fn write_data_root_pointer(
file: &std::path::Path,
path: &std::path::Path,
) -> std::io::Result<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::json!({ "path": path.to_string_lossy() }).to_string();
let temp = file.with_extension("json.tmp");
std::fs::write(&temp, body.as_bytes())?;
std::fs::rename(&temp, file)
}
/// Forget a recorded data directory, returning the app to the default.
pub fn clear_data_root_pointer(file: &std::path::Path) -> std::io::Result<()> {
match std::fs::remove_file(file) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
/// The data directory a previous move chose, read once per process.
///
/// Cached deliberately. Every open handle, cached path and loaded manager in a
/// running app points at the directory it started on, so a move must take
/// effect at the NEXT start and never mid-session.
pub fn custom_data_root() -> Option<&'static PathBuf> {
CUSTOM_DATA_ROOT
.get_or_init(|| read_data_root_pointer(&data_root_pointer_file()))
.as_ref()
}
/// True when app state has been moved off the platform default location, by
/// portable mode, either directory override, or a data directory the user
/// chose in Settings.
/// portable mode or by either directory override.
fn state_is_relocated() -> bool {
std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty())
|| custom_data_root().is_some()
|| data_root().is_some()
|| portable_dir().is_some()
}
@@ -252,66 +141,19 @@ pub fn data_dir() -> PathBuf {
}
}
data_dir_for(
std::env::var_os("DONUTBROWSER_DATA_DIR")
.filter(|v| !v.is_empty())
.map(PathBuf::from),
custom_data_root(),
data_root(),
portable_dir(),
base_dirs().data_local_dir().join(app_name()),
)
}
if let Ok(dir) = std::env::var("DONUTBROWSER_DATA_DIR") {
return PathBuf::from(dir);
}
/// The data directory resolution order, split out so it can be tested without
/// mutating process-wide environment variables.
///
/// `DONUTBROWSER_DATA_DIR` stays on top: it names an exact directory and is the
/// bluntest override there is. The directory the user picked in Settings comes
/// next, ahead of `DONUTBROWSER_DATA_ROOT` and portable mode, because both of
/// those are defaults for where state *would* live and an explicit choice
/// outranks a default. It cannot break an isolated run, because the pointer it
/// is read from lives under that same `DONUTBROWSER_DATA_ROOT`.
fn data_dir_for(
env_data_dir: Option<PathBuf>,
custom_root: Option<&PathBuf>,
env_data_root: Option<PathBuf>,
portable: Option<&PathBuf>,
platform_default: PathBuf,
) -> PathBuf {
if let Some(dir) = env_data_dir {
return dir;
}
if let Some(dir) = custom_root {
return dir.clone();
}
if let Some(root) = env_data_root {
if let Some(root) = data_root() {
return root.join("data");
}
if let Some(dir) = portable {
if let Some(dir) = portable_dir() {
return dir.join("data");
}
platform_default
}
/// Where the data directory would resolve with no user choice recorded. Shown
/// in Settings so a person can see what they moved away from.
pub fn default_data_dir() -> PathBuf {
data_dir_for(
std::env::var_os("DONUTBROWSER_DATA_DIR")
.filter(|v| !v.is_empty())
.map(PathBuf::from),
None,
data_root(),
portable_dir(),
base_dirs().data_local_dir().join(app_name()),
)
}
/// True when an environment override decides the data directory, so a
/// directory chosen in Settings would be recorded but not used.
pub fn data_dir_forced_by_environment() -> bool {
std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty())
base_dirs().data_local_dir().join(app_name())
}
pub fn cache_dir() -> PathBuf {
@@ -618,111 +460,6 @@ mod tests {
assert!(portable.join("cache").starts_with(&portable));
}
#[test]
fn data_dir_resolution_order_puts_the_chosen_directory_under_the_exact_override() {
let env_dir = PathBuf::from("/env/exact");
let chosen = PathBuf::from("/Volumes/Big/DonutBrowser");
let env_root = PathBuf::from("/env/root");
let portable = PathBuf::from("/stick");
let default = PathBuf::from("/home/user/.local/share/DonutBrowser");
// DONUTBROWSER_DATA_DIR names an exact directory and outranks everything.
assert_eq!(
data_dir_for(
Some(env_dir.clone()),
Some(&chosen),
Some(env_root.clone()),
Some(&portable),
default.clone(),
),
env_dir
);
// The directory the user picked beats both defaults-for-where-state-lives.
assert_eq!(
data_dir_for(
None,
Some(&chosen),
Some(env_root.clone()),
Some(&portable),
default.clone(),
),
chosen
);
// With nothing chosen the existing order is untouched.
assert_eq!(
data_dir_for(
None,
None,
Some(env_root.clone()),
Some(&portable),
default.clone(),
),
env_root.join("data")
);
assert_eq!(
data_dir_for(None, None, None, Some(&portable), default.clone()),
portable.join("data")
);
assert_eq!(
data_dir_for(None, None, None, None, default.clone()),
default
);
}
#[test]
fn the_pointer_never_lives_inside_the_directory_it_points_at() {
let root = PathBuf::from("/tmp/donut-root");
let portable = PathBuf::from("/tmp/donut-portable");
let preference = PathBuf::from("/home/user/.config/DonutBrowser");
// With DONUTBROWSER_DATA_ROOT the data dir is <root>/data, so a sibling
// file survives deleting it — and an isolated run reads only its own.
let with_root =
data_root_pointer_file_for(Some(root.clone()), Some(&portable), preference.clone());
assert_eq!(with_root, root.join(DATA_ROOT_POINTER_FILENAME));
assert!(!with_root.starts_with(root.join("data")));
let with_portable = data_root_pointer_file_for(None, Some(&portable), preference.clone());
assert_eq!(with_portable, portable.join(DATA_ROOT_POINTER_FILENAME));
assert!(!with_portable.starts_with(portable.join("data")));
assert_eq!(
data_root_pointer_file_for(None, None, preference.clone()),
preference.join(DATA_ROOT_POINTER_FILENAME)
);
}
#[test]
fn a_written_pointer_reads_back_and_a_broken_one_falls_back() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("nested").join(DATA_ROOT_POINTER_FILENAME);
let target = std::env::temp_dir().join("donut-moved-root");
assert_eq!(read_data_root_pointer(&file), None, "missing file");
write_data_root_pointer(&file, &target).unwrap();
assert_eq!(read_data_root_pointer(&file), Some(target.clone()));
// A relative path would be resolved against whatever the working directory
// happens to be, which is not a place app state can live.
write_data_root_pointer(&file, std::path::Path::new("relative/root")).unwrap();
assert_eq!(read_data_root_pointer(&file), None, "relative path");
std::fs::write(&file, b"not json at all").unwrap();
assert_eq!(read_data_root_pointer(&file), None, "malformed file");
std::fs::write(&file, br#"{"other":"key"}"#).unwrap();
assert_eq!(read_data_root_pointer(&file), None, "no path entry");
write_data_root_pointer(&file, &target).unwrap();
clear_data_root_pointer(&file).unwrap();
assert_eq!(read_data_root_pointer(&file), None, "cleared");
// Clearing an absent pointer is not an error; the caller has nothing to fix.
clear_data_root_pointer(&file).unwrap();
}
#[test]
fn test_subdirectory_helpers() {
assert!(profiles_dir().ends_with("profiles"));
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -63,7 +63,6 @@ mod browser_runner;
mod browser_version_manager;
mod cdp_target;
mod checksum;
mod data_root;
mod default_browser;
pub mod dns_blocklist;
mod downloaded_browsers_registry;
@@ -85,6 +84,7 @@ mod launch_gate_prefs;
mod log_redaction;
mod platform_browser;
mod profile;
mod profile_generation_limiter;
mod profile_import;
mod profile_importer;
mod proxy_distribution;
@@ -3494,9 +3494,6 @@ pub fn run_with_builder(
mark_tip_seen,
set_tips_auto_show,
observe_cloud_plan,
data_root::get_data_root_info,
data_root::move_data_root,
data_root::clear_data_root_choice,
clear_all_version_cache_and_refetch,
is_default_browser,
open_url_with_profile,
+11 -4
View File
@@ -5586,10 +5586,17 @@ impl McpServer {
launch_hook,
)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to create profile: {e}"),
data: None,
.map_err(|e| {
let message = e.to_string();
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}");
McpError {
code: -32000,
message: format!("Failed to create profile: {message}"),
data: None,
}
})?;
if temporary {
+27 -3
View File
@@ -274,6 +274,23 @@ impl ProfileManager {
// Generate a device if the profile has neither a legacy payload nor an
// identity.
if config.fingerprint.is_none() && config.identity_id.is_none() {
if let crate::profile_generation_limiter::GenerationOutcome::Limited { retry_after_secs } =
crate::profile_generation_limiter::record_profile_generation().await
{
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; retry in {retry_after_secs}s"
);
return Err(
serde_json::json!({
"code": "PROFILE_GENERATION_LIMIT_REACHED",
"params": { "retryAfterSeconds": retry_after_secs.to_string() }
})
.to_string()
.into(),
);
}
log::info!("Generating fingerprint for Wayfern profile: {name}");
// Create a temporary profile for fingerprint generation
@@ -336,9 +353,17 @@ impl ProfileManager {
log::info!("Successfully generated fingerprint for Wayfern profile: {name}");
}
Err(e) => {
return Err(
format!("Failed to generate fingerprint for Wayfern profile '{name}': {e}").into(),
// Coded, not wrapped: re-`format!`ing the error here left a string
// no layer above could classify, so a refusal answered 400 with no
// trace of which rule refused.
let coded = crate::wayfern_manager::wayfern_failure(
&e.to_string(),
"WAYFERN_FINGERPRINT_GENERATION_FAILED",
config.os.as_deref(),
);
let _ = std::fs::remove_dir_all(&profile_uuid_dir);
log::error!("Could not create Wayfern profile '{name}': {coded}");
return Err(coded.into());
}
}
} else {
@@ -753,7 +778,6 @@ impl ProfileManager {
&profiles_dir,
&crate::profile::trash::trash_dir(),
&profile,
crate::profile::trash::configured_retention_days(),
crate::proxy_manager::now_secs(),
)?;
}
+22 -45
View File
@@ -27,9 +27,8 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
pub const DEFAULT_RETENTION_DAYS: u32 = 30;
pub const MIN_RETENTION_DAYS: u32 = 1;
pub const MAX_RETENTION_DAYS: u32 = 365;
/// How long a deleted profile can still be restored.
pub const RETENTION_DAYS: u32 = 30;
/// How often expired entries are swept while the app runs.
pub const PURGE_INTERVAL_SECS: u64 = 6 * 60 * 60;
@@ -90,18 +89,6 @@ pub fn mutation_lock() -> MutexGuard<'static, ()> {
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn clamp_retention_days(days: u32) -> u32 {
days.clamp(MIN_RETENTION_DAYS, MAX_RETENTION_DAYS)
}
/// The retention the user configured, already clamped to the allowed range.
pub fn configured_retention_days() -> u32 {
crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|settings| clamp_retention_days(settings.trash_retention_days))
.unwrap_or(DEFAULT_RETENTION_DAYS)
}
fn err_internal(e: impl std::fmt::Display) -> String {
crate::backend_error_with_detail("INTERNAL_ERROR", e)
}
@@ -203,7 +190,6 @@ pub fn trash_profile(
profiles_dir: &Path,
trash_root: &Path,
profile: &BrowserProfile,
retention_days: u32,
now: u64,
) -> Result<TrashManifest, String> {
let id = profile.id.to_string();
@@ -239,7 +225,7 @@ pub fn trash_profile(
let manifest = TrashManifest {
deleted_at: now,
expires_at: now.saturating_add(u64::from(clamp_retention_days(retention_days)) * SECS_PER_DAY),
expires_at: now.saturating_add(u64::from(RETENTION_DAYS) * SECS_PER_DAY),
size_bytes: dir_size(&target_dir.join(DATA_DIR)),
original_name: profile.name.clone(),
};
@@ -576,9 +562,12 @@ mod tests {
let profiles_dir = seed_profile(root.path(), &profile, true);
let trash_root = root.path().join("trash");
let manifest = trash_profile(&profiles_dir, &trash_root, &profile, 30, NOW).unwrap();
let manifest = trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
assert_eq!(manifest.deleted_at, NOW);
assert_eq!(manifest.expires_at, NOW + 30 * SECS_PER_DAY);
assert_eq!(
manifest.expires_at,
NOW + u64::from(RETENTION_DAYS) * SECS_PER_DAY
);
assert_eq!(manifest.original_name, "Shop Account");
assert!(manifest.size_bytes > 0);
@@ -653,7 +642,7 @@ mod tests {
let profile = sample_profile("Shop Account");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
let mut twin = sample_profile("shop account");
twin.id = uuid::Uuid::new_v4();
@@ -687,7 +676,7 @@ mod tests {
let profile = sample_profile("Shop Account");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
let err = restore_profile(
&profiles_dir,
@@ -726,7 +715,7 @@ mod tests {
let profile = sample_profile("Grouped");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
let restored = restore_profile(
&profiles_dir,
@@ -749,7 +738,7 @@ mod tests {
profile.encryption_salt = Some("salt".to_string());
let profiles_dir = seed_profile(root.path(), &profile, true);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
let entry_dir = trash_root.join(profile.id.to_string());
for relative in CACHE_DIRS {
@@ -781,12 +770,13 @@ mod tests {
let profiles_dir = seed_profile(root.path(), &old, false);
seed_profile(root.path(), &fresh, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &old, 1, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &fresh, 30, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &old, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &fresh, NOW + SECS_PER_DAY).unwrap();
assert_eq!(summaries(&trash_root).len(), 2);
assert!(purge_expired(&trash_root, NOW + SECS_PER_DAY - 1).is_empty());
let purged = purge_expired(&trash_root, NOW + SECS_PER_DAY);
let old_expiry = NOW + u64::from(RETENTION_DAYS) * SECS_PER_DAY;
assert!(purge_expired(&trash_root, old_expiry - 1).is_empty());
let purged = purge_expired(&trash_root, old_expiry);
assert_eq!(purged, vec![old.id.to_string()]);
let remaining = summaries(&trash_root);
assert_eq!(remaining.len(), 1);
@@ -794,19 +784,6 @@ mod tests {
assert!(!trash_root.join(old.id.to_string()).exists());
}
#[test]
fn retention_is_clamped_to_the_allowed_range() {
assert_eq!(clamp_retention_days(0), MIN_RETENTION_DAYS);
assert_eq!(clamp_retention_days(30), 30);
assert_eq!(clamp_retention_days(10_000), MAX_RETENTION_DAYS);
let root = TempDir::new().unwrap();
let profile = sample_profile("Clamped");
let profiles_dir = seed_profile(root.path(), &profile, false);
let manifest =
trash_profile(&profiles_dir, &root.path().join("trash"), &profile, 0, NOW).unwrap();
assert_eq!(manifest.expires_at, NOW + SECS_PER_DAY);
}
#[test]
fn empty_trash_removes_every_entry() {
let root = TempDir::new().unwrap();
@@ -815,8 +792,8 @@ mod tests {
let profiles_dir = seed_profile(root.path(), &first, false);
seed_profile(root.path(), &second, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &first, 7, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &second, 7, NOW + 1).unwrap();
trash_profile(&profiles_dir, &trash_root, &first, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &second, NOW + 1).unwrap();
let listed = summaries(&trash_root);
assert_eq!(listed[0].name, "Second", "newest deletion is listed first");
@@ -834,7 +811,7 @@ mod tests {
let profile = sample_profile("Twice");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
restore_profile(
&profiles_dir,
&trash_root,
@@ -850,7 +827,7 @@ mod tests {
fs::create_dir_all(trash_root.join(profile.id.to_string())).unwrap();
fs::write(trash_root.join(profile.id.to_string()).join("stale"), b"x").unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW + 5).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW + 5).unwrap();
let entry_dir = trash_root.join(profile.id.to_string());
assert!(!entry_dir.join("stale").exists());
assert_eq!(
@@ -866,7 +843,7 @@ mod tests {
let profile = sample_profile("Good");
let profiles_dir = seed_profile(root.path(), &profile, false);
let trash_root = root.path().join("trash");
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
trash_profile(&profiles_dir, &trash_root, &profile, NOW).unwrap();
let broken = trash_root.join("broken-entry");
fs::create_dir_all(&broken).unwrap();
fs::write(broken.join("profile.json"), b"not json").unwrap();
+174
View File
@@ -0,0 +1,174 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use crate::cloud_auth::CLOUD_AUTH;
const GENERATION_WINDOW: Duration = Duration::from_secs(60 * 60);
const PRO_GENERATIONS_PER_HOUR: u64 = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GenerationOutcome {
Unlimited,
Allowed { remaining: u64 },
Limited { retry_after_secs: u64 },
}
#[derive(Default)]
struct ProfileGenerationLimiter {
generations: HashMap<String, VecDeque<Instant>>,
}
impl ProfileGenerationLimiter {
fn record_at(&mut self, identity: &str, per_hour: u64, now: Instant) -> GenerationOutcome {
if per_hour == 0 {
return GenerationOutcome::Unlimited;
}
self.generations.retain(|_, seen| {
while seen
.front()
.is_some_and(|started| now.duration_since(*started) >= GENERATION_WINDOW)
{
seen.pop_front();
}
!seen.is_empty()
});
let seen = self.generations.entry(identity.to_string()).or_default();
if seen.len() as u64 >= per_hour {
let retry_after_secs = seen
.front()
.map(|started| {
let remaining = GENERATION_WINDOW.saturating_sub(now.duration_since(*started));
remaining
.as_secs()
.saturating_add(u64::from(remaining.subsec_nanos() > 0))
.max(1)
})
.unwrap_or(1);
return GenerationOutcome::Limited { retry_after_secs };
}
seen.push_back(now);
GenerationOutcome::Allowed {
remaining: per_hour.saturating_sub(seen.len() as u64),
}
}
}
static PROFILE_GENERATION_LIMITER: LazyLock<Mutex<ProfileGenerationLimiter>> =
LazyLock::new(|| Mutex::new(ProfileGenerationLimiter::default()));
/// Identity and cap for the accounts this limiter applies to. Only `pro` is
/// capped; every other plan returns `None` and stays unlimited here.
async fn capped_identity() -> Option<(String, u64)> {
let state = CLOUD_AUTH.get_user().await?;
if !state.user.entitlements().active || state.user.effective_plan() != "pro" {
return None;
}
Some((state.user.id.clone(), PRO_GENERATIONS_PER_HOUR))
}
pub async fn record_profile_generation() -> GenerationOutcome {
let Some((identity, per_hour)) = capped_identity().await else {
return GenerationOutcome::Unlimited;
};
PROFILE_GENERATION_LIMITER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.record_at(&identity, per_hour, Instant::now())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_hundred_and_first_generation_in_an_hour_is_refused() {
let mut limiter = ProfileGenerationLimiter::default();
let now = Instant::now();
for i in 0..PRO_GENERATIONS_PER_HOUR {
assert_eq!(
limiter.record_at(
"user-a",
PRO_GENERATIONS_PER_HOUR,
now + Duration::from_secs(i)
),
GenerationOutcome::Allowed {
remaining: PRO_GENERATIONS_PER_HOUR - i - 1
}
);
}
assert_eq!(
limiter.record_at(
"user-a",
PRO_GENERATIONS_PER_HOUR,
now + Duration::from_secs(PRO_GENERATIONS_PER_HOUR)
),
GenerationOutcome::Limited {
retry_after_secs: 3600 - PRO_GENERATIONS_PER_HOUR
}
);
}
#[test]
fn the_window_rolls_so_the_oldest_slot_comes_back() {
let mut limiter = ProfileGenerationLimiter::default();
let now = Instant::now();
assert_eq!(
limiter.record_at("user-a", 2, now),
GenerationOutcome::Allowed { remaining: 1 }
);
assert_eq!(
limiter.record_at("user-a", 2, now + Duration::from_secs(10)),
GenerationOutcome::Allowed { remaining: 0 }
);
assert_eq!(
limiter.record_at("user-a", 2, now + Duration::from_secs(20)),
GenerationOutcome::Limited {
retry_after_secs: 3580
}
);
assert_eq!(
limiter.record_at("user-a", 2, now + GENERATION_WINDOW),
GenerationOutcome::Allowed { remaining: 0 }
);
}
#[test]
fn each_account_gets_its_own_window() {
let mut limiter = ProfileGenerationLimiter::default();
let now = Instant::now();
assert_eq!(
limiter.record_at("user-a", 1, now),
GenerationOutcome::Allowed { remaining: 0 }
);
assert!(matches!(
limiter.record_at("user-a", 1, now + Duration::from_secs(1)),
GenerationOutcome::Limited { .. }
));
assert_eq!(
limiter.record_at("user-b", 1, now + Duration::from_secs(1)),
GenerationOutcome::Allowed { remaining: 0 }
);
}
#[test]
fn a_zero_cap_never_limits() {
let mut limiter = ProfileGenerationLimiter::default();
let now = Instant::now();
for i in 0..500 {
assert_eq!(
limiter.record_at("user-a", 0, now + Duration::from_secs(i)),
GenerationOutcome::Unlimited
);
}
}
}
-30
View File
@@ -87,11 +87,6 @@ pub struct AppSettings {
/// copy is always re-encrypted regardless of this flag.
#[serde(default)]
pub keep_decrypted_profiles_in_ram: bool,
/// How long a deleted profile stays in the trash before it is purged.
/// Clamped to 1..=365 on save; the sweeper reads it through
/// `profile::trash::configured_retention_days`.
#[serde(default = "default_trash_retention_days")]
pub trash_retention_days: u32,
/// Feature tips. Whether one tip the user has not seen yet may open by
/// itself shortly after launch. Off is the user's choice, made in the tips
/// dialog.
@@ -128,10 +123,6 @@ fn default_api_port() -> u16 {
10108
}
fn default_trash_retention_days() -> u32 {
crate::profile::trash::DEFAULT_RETENTION_DAYS
}
fn default_tips_auto_show() -> bool {
true
}
@@ -169,7 +160,6 @@ impl Default for AppSettings {
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
trash_retention_days: crate::profile::trash::DEFAULT_RETENTION_DAYS,
tips_auto_show: true,
tips_seen: Vec::new(),
tips_last_auto_shown_at: None,
@@ -649,9 +639,6 @@ pub async fn save_app_settings(
settings.mcp_remote_key_id = None;
}
settings.trash_retention_days =
crate::profile::trash::clamp_retention_days(settings.trash_retention_days);
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
persist_settings.mcp_token = None;
@@ -1287,7 +1274,6 @@ mod tests {
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
trash_retention_days: 14,
tips_auto_show: true,
tips_seen: Vec::new(),
tips_last_auto_shown_at: None,
@@ -1310,22 +1296,6 @@ mod tests {
loaded_settings.theme, "dark",
"Loaded theme should match saved"
);
assert_eq!(loaded_settings.trash_retention_days, 14);
}
#[test]
fn trash_retention_defaults_when_the_settings_file_predates_it() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let settings_dir = manager.get_settings_dir();
create_dir_all(&settings_dir).unwrap();
fs::write(manager.get_settings_file(), r#"{"theme":"light"}"#).unwrap();
let loaded = manager.load_settings().unwrap();
assert_eq!(loaded.theme, "light");
assert_eq!(
loaded.trash_retention_days,
crate::profile::trash::DEFAULT_RETENTION_DAYS
);
}
#[test]
+55 -4
View File
@@ -343,7 +343,11 @@ pub async fn convert_for_launch(profile: &BrowserProfile) -> Result<BrowserProfi
if !needs_conversion(&current) {
return Ok(current);
}
let token = token_for_conversion().await?;
// A LAUNCH NEVER FAILS OVER PLAN STATE. A token that does not arrive means
// the caller is unentitled, signed out, or the verifier is briefly
// unreachable -- none of which is a reason the browser should not start. It
// proceeds without one and the browser drops what the plan does not carry.
let token = token_for_conversion().await.unwrap_or(None);
let session = HeadlessWayfern::start(
WayfernManager::instance(),
&current,
@@ -352,11 +356,11 @@ pub async fn convert_for_launch(profile: &BrowserProfile) -> Result<BrowserProfi
)
.await
.map_err(|e| wayfern_failure(&e.to_string(), "WAYFERN_FINGERPRINT_APPLY_FAILED", None))?;
let mut result = convert_and_save(&session, &current, false, true).await;
let mut result = convert_and_save(&session, &current, false, false).await;
if matches!(result, Err(ConversionError::Changed)) {
result = match read_profile(&profile.id) {
Some(latest) if needs_conversion(&latest) && latest.version == current.version => {
convert_and_save(&session, &latest, false, true).await
convert_and_save(&session, &latest, false, false).await
}
Some(latest) if !needs_conversion(&latest) => Ok(latest),
Some(_) => Err(ConversionError::Changed),
@@ -366,9 +370,24 @@ pub async fn convert_for_launch(profile: &BrowserProfile) -> Result<BrowserProfi
};
}
session.stop().await;
launch_outcome(result, current)
}
/// What a conversion result means to the person clicking launch.
///
/// CONVERSION IS NOT A PRECONDITION FOR LAUNCHING. A refusal, a transient
/// verifier failure and a browser that did not answer all mean the same thing
/// here: nothing yet. The profile still has its stored device, so it opens on
/// that and the background sweep converts it later, once whatever was briefly
/// true has stopped being true. Returning an error instead turns "your plan
/// does not include custom fingerprints" into "your browser does not start".
fn launch_outcome(
result: Result<BrowserProfile, ConversionError>,
current: BrowserProfile,
) -> Result<BrowserProfile, String> {
match result {
Ok(converted) => Ok(converted),
Err(ConversionError::Failed(reason)) => {
Err(ConversionError::Failed(reason)) | Err(ConversionError::Deferred(reason)) => {
log::warn!(
"Launching Wayfern profile {} on its stored device: {reason}",
current.id
@@ -859,6 +878,38 @@ mod tests {
assert!(!launch_pending());
}
#[test]
fn a_launch_never_fails_because_the_conversion_could_not_finish() {
// The rule this pins: plan state, a transient verifier failure and a
// browser that did not answer must all still open the profile on its
// stored device. Returning an error here is a launch outage, and it has
// reached a user twice.
let stored = profile_with("152.0.7977.64", WayfernConfig::default());
for refusal in [
ConversionError::Failed(crate::backend_error("WAYFERN_FINGERPRINT_APPLY_FAILED")),
ConversionError::Deferred(crate::backend_error("WAYFERN_PLAN_CHECK_UNAVAILABLE")),
] {
let label = format!("{refusal:?}");
let outcome = launch_outcome(Err(refusal), stored.clone());
assert_eq!(
outcome.expect("the profile still launches").id,
stored.id,
"{label} must fall back to the stored device"
);
}
// A successful conversion is what the caller gets instead.
let converted = profile_with("152.0.7977.64", WayfernConfig::default());
let converted_id = converted.id;
assert_eq!(
launch_outcome(Ok(converted), stored.clone())
.expect("a converted profile launches")
.id,
converted_id
);
}
#[test]
fn protocol_refusals_are_permanent_and_everything_else_waits() {
let invalid =
+41 -222
View File
@@ -463,154 +463,6 @@ pub fn is_temporary_wayfern_failure(coded: &str) -> bool {
.any(|code| coded.contains(&format!("\"{code}\"")))
}
/// Fonts for the window badge, loaded from the system once per process. The
/// load walks every font directory, which is far too slow to repeat per launch.
fn badge_fonts() -> std::sync::Arc<resvg::usvg::fontdb::Database> {
static FONTS: std::sync::OnceLock<std::sync::Arc<resvg::usvg::fontdb::Database>> =
std::sync::OnceLock::new();
FONTS
.get_or_init(|| {
let mut db = resvg::usvg::fontdb::Database::new();
db.load_system_fonts();
if let Some(family) = badge_sans_family(&db) {
db.set_sans_serif_family(family);
}
std::sync::Arc::new(db)
})
.clone()
}
/// The family the badge's `sans-serif` resolves to.
///
/// The database names Arial for the generic family, which macOS and Windows
/// have and a Linux desktop usually does not: Ubuntu ships Noto, DejaVu,
/// Liberation and Ubuntu instead. An unresolved family draws no initial at
/// all, so the first family that is actually installed is chosen, and failing
/// every known name, any installed font at all.
fn badge_sans_family(db: &resvg::usvg::fontdb::Database) -> Option<String> {
use resvg::usvg::fontdb::{Family, Query, Stretch, Style, Weight};
const PREFERRED: [&str; 10] = [
"Arial",
"Helvetica Neue",
"Helvetica",
"Segoe UI",
"Noto Sans",
"DejaVu Sans",
"Liberation Sans",
"Ubuntu",
"Cantarell",
"Roboto",
];
let installed = |name: &str| {
db.query(&Query {
families: &[Family::Name(name)],
weight: Weight::NORMAL,
stretch: Stretch::Normal,
style: Style::Normal,
})
.is_some()
};
PREFERRED
.iter()
.find(|name| installed(name))
.map(|name| name.to_string())
.or_else(|| {
db.faces()
.find_map(|face| face.families.first().map(|(name, _)| name.clone()))
})
}
/// The first letter (or digit) of a profile name, upper-cased, for its badge.
pub fn badge_initial(name: &str) -> String {
name
.chars()
.find(|c| c.is_alphanumeric())
.map(|c| c.to_uppercase().collect())
.unwrap_or_default()
}
/// Whether text on `color` (bare or `#`-prefixed RRGGBB) reads better dark.
fn badge_wants_dark_ink(color: &str) -> bool {
let hex = color.trim().trim_start_matches('#');
if hex.len() != 6 {
return false;
}
let channel = |i: usize| {
u8::from_str_radix(&hex[i..i + 2], 16)
.map(|v| v as f64 / 255.0)
.unwrap_or(0.0)
};
// Relative luminance, sRGB weights; 0.6 keeps white ink on every mid tone.
0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4) > 0.6
}
/// Render the PNG a 152 browser shows as this profile's window, taskbar and
/// Dock icon: the profile's frame colour with its initial. `None` when the
/// badge cannot be rendered, in which case the browser keeps its stock icon.
pub fn render_profile_icon(name: &str, color: &str) -> Option<Vec<u8>> {
use resvg::tiny_skia;
let hex = color.trim().trim_start_matches('#');
if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let initial = badge_initial(name);
let ink = if badge_wants_dark_ink(hex) {
"#1b1b1b"
} else {
"#ffffff"
};
let escaped = initial
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect x="16" y="16" width="224" height="224" rx="56" fill="#{hex}"/>
<text x="128" y="128" text-anchor="middle" dominant-baseline="central" font-family="sans-serif" font-weight="700" font-size="140" fill="{ink}">{escaped}</text>
</svg>"##
);
let options = resvg::usvg::Options {
fontdb: badge_fonts(),
..Default::default()
};
let tree = resvg::usvg::Tree::from_str(&svg, &options).ok()?;
let mut pixmap = tiny_skia::Pixmap::new(256, 256)?;
resvg::render(
&tree,
tiny_skia::Transform::identity(),
&mut pixmap.as_mut(),
);
pixmap.encode_png().ok()
}
/// Write the profile's window badge beside its data directory and return the
/// switch that hands it to a 152 browser. Older browsers get nothing.
pub fn profile_icon_switch(
version: &str,
profile_path: &str,
name: &str,
color: &str,
) -> Option<String> {
if !supports_wayfern_152(version) {
return None;
}
let png = render_profile_icon(name, color)?;
let dir = Path::new(profile_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from(profile_path));
let path = dir.join("window-icon.png");
if let Err(e) = std::fs::write(&path, png) {
log::warn!(
"Could not write the window badge for profile {name} at {}: {e}; the browser keeps its stock icon",
path.display()
);
return None;
}
let path = path.canonicalize().unwrap_or(path);
Some(format!("--wayfern-profile-icon={}", path.display()))
}
/// Write the profile's persona beside its data directory and return the switch
/// that hands it to a 152 browser. The document is DERIVED from `seed` with
/// the user's edits applied, so it is stable per profile and unique to it.
@@ -918,26 +770,38 @@ impl HeadlessWayfern {
return Err(e);
}
let targets = match manager.get_cdp_targets(port).await {
Ok(targets) => targets,
Err(e) => {
// The debugging endpoint answers as soon as the process is up, which is
// BEFORE the first window exists, so the target list is legitimately empty
// for a moment and a single read races it. Poll until a page target
// appears, and stop early if the process dies so a refused start is
// reported as its own reason rather than as a timeout.
let deadline = std::time::Instant::now() + Duration::from_secs(30);
loop {
if let Ok(Some(status)) = session.child.try_wait() {
tokio::time::sleep(Duration::from_millis(300)).await;
let said = log.lines().join("\n");
session.stop().await;
return Err(e);
return Err(early_exit_error(&said, &status).into());
}
};
match targets
.into_iter()
.find(|t| t.target_type == "page")
.and_then(|t| t.websocket_debugger_url)
{
Some(url) => {
session.page_ws_url = url;
Ok(session)
}
None => {
let detail = match manager.get_cdp_targets(port).await {
Ok(targets) => {
if let Some(url) = targets
.into_iter()
.find(|t| t.target_type == "page")
.and_then(|t| t.websocket_debugger_url)
{
session.page_ws_url = url;
return Ok(session);
}
"the browser reported no page target".to_string()
}
Err(e) => e.to_string(),
};
if std::time::Instant::now() >= deadline {
session.stop().await;
Err("No page target found for CDP".into())
return Err(format!("No page target found for CDP: {detail}").into());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
@@ -2310,7 +2174,19 @@ impl WayfernManager {
} else {
"get fingerprint"
};
return Err(format!("Failed to {what}: {e}").into());
let detail = e.to_string();
let coded = wayfern_failure(
&detail,
"WAYFERN_FINGERPRINT_GENERATION_FAILED",
config.os.as_deref(),
);
log::error!(
"Wayfern refused to {what} for profile {}: {} (browser said: {})",
profile.name,
coded,
cdp_error_message(&detail)
);
return Err(coded.into());
}
};
@@ -2747,12 +2623,6 @@ impl WayfernManager {
&profile.version,
&crate::app_dirs::cache_dir(),
));
args.extend(profile_icon_switch(
&profile.version,
profile_path,
&profile.name,
profile_color,
));
// The persona is a property of the profile, so an identity-backed profile
// seeds it from the identity and a legacy one from its id: either way the
// same profile presents the same person on every launch.
@@ -4141,57 +4011,6 @@ mod tests {
);
}
#[test]
fn the_window_badge_is_a_decodable_png_in_the_profile_colour() {
assert_eq!(badge_initial(" donut shop"), "D");
assert_eq!(badge_initial("42 things"), "4");
assert_eq!(badge_initial("!!!"), "");
assert!(badge_wants_dark_ink("#f5e6a0"));
assert!(!badge_wants_dark_ink("#2b4c7e"));
assert_eq!(render_profile_icon("Any", "not a colour"), None);
let png = render_profile_icon("Donut", "#2b4c7e").expect("the badge renders");
let image = image::load_from_memory(&png)
.expect("a decodable PNG")
.into_rgba8();
assert_eq!((image.width(), image.height()), (256, 256));
// Inside the rounded square, away from the initial: the profile colour.
assert_eq!(image.get_pixel(40, 128).0, [0x2b, 0x4c, 0x7e, 0xff]);
// The corners stay transparent so the badge reads as a tile, not a sheet.
assert_eq!(image.get_pixel(2, 2).0[3], 0);
// The initial is drawn in white ink somewhere in the middle third when the
// machine has any font at all. Not sampled at the exact centre: that is
// the counter of a "D", which stays the fill colour.
if !badge_fonts().is_empty() {
let ink = (80..176)
.flat_map(|y| (80..176).map(move |x| (x, y)))
.any(|(x, y)| {
let p = image.get_pixel(x, y).0;
p[0] > 0xc0 && p[1] > 0xc0 && p[2] > 0xc0
});
assert!(ink, "the initial must be drawn in the middle of the badge");
}
}
#[test]
fn the_profile_icon_switch_writes_the_badge_beside_the_data_dir() {
let root = tempfile::tempdir().unwrap();
let data_dir = root.path().join("profile");
std::fs::create_dir_all(&data_dir).unwrap();
let data_dir = data_dir.to_string_lossy().to_string();
assert_eq!(
profile_icon_switch("151.0.7922.76", &data_dir, "Donut", "ebb5ad"),
None
);
let switch = profile_icon_switch("152.0.7977.64", &data_dir, "Donut", "ebb5ad").unwrap();
let expected = root.path().join("window-icon.png").canonicalize().unwrap();
assert_eq!(
switch,
format!("--wayfern-profile-icon={}", expected.display())
);
assert!(image::load_from_memory(&std::fs::read(expected).unwrap()).is_ok());
}
#[test]
fn the_persona_document_is_written_per_profile_and_only_on_152() {
let root = tempfile::tempdir().unwrap();