From f05aaa2cf2bc7b85643fda43f8a5963c4b4e74d4 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:17:24 +0400 Subject: [PATCH] refactor: cleanup --- AGENTS.md | 1 - e2e/coverage-map.mjs | 3 - e2e/tests/entities.test.mjs | 21 +- e2e/tests/smoke.test.mjs | 113 -- e2e/tests/ui.test.mjs | 132 --- src-tauri/src/api_server.rs | 16 +- src-tauri/src/app_dirs.rs | 279 +---- src-tauri/src/data_root.rs | 1036 ------------------- src-tauri/src/lib.rs | 5 +- src-tauri/src/mcp_server.rs | 15 +- src-tauri/src/profile/manager.rs | 30 +- src-tauri/src/profile/trash.rs | 67 +- src-tauri/src/profile_generation_limiter.rs | 174 ++++ src-tauri/src/settings_manager.rs | 30 - src-tauri/src/wayfern_identity_storage.rs | 59 +- src-tauri/src/wayfern_manager.rs | 263 +---- src/app/page.tsx | 16 + src/components/data-root-setting.tsx | 276 ----- src/components/settings-dialog.tsx | 70 -- src/i18n/locales/en.json | 5 + src/i18n/locales/es.json | 5 + src/i18n/locales/fr.json | 5 + src/i18n/locales/ja.json | 5 + src/i18n/locales/ko.json | 5 + src/i18n/locales/pt.json | 5 + src/i18n/locales/ru.json | 5 + src/i18n/locales/tr.json | 5 + src/i18n/locales/vi.json | 5 + src/i18n/locales/zh.json | 5 + src/lib/backend-errors.ts | 59 +- src/types.ts | 22 - 31 files changed, 432 insertions(+), 2305 deletions(-) delete mode 100644 src-tauri/src/data_root.rs create mode 100644 src-tauri/src/profile_generation_limiter.rs delete mode 100644 src/components/data-root-setting.tsx diff --git a/AGENTS.md b/AGENTS.md index d02db984..fdcad10a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,6 @@ donutbrowser/ │ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi) │ │ ├── settings_manager.rs # App settings persistence (atomic writes), tips + paid-welcome state │ │ ├── vault.rs # Per-install key that seals local secrets; opens legacy build-password seals once -│ │ ├── data_root.rs # Moving the data directory (copy, verify, then delete) + the pointer read at startup │ │ ├── cookie_manager.rs # Cookie import/export │ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch) │ │ ├── fingerprint_consistency.rs # Launch-time proxy exit vs fingerprint timezone/language check diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 65c09ca8..444da5e4 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -32,9 +32,6 @@ export const commandCoverage = { "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", ], }, profileEntities: { diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index dbcf7bc4..1edc8d92 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -1076,25 +1076,6 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear test("deleted profiles land in the trash and come back intact on restore", async () => { await withApp("entities-trash", async (app) => { - const initialSettings = await app.invoke("get_app_settings"); - assert.equal(initialSettings.trash_retention_days, 30); - const savedSettings = await app.invoke("save_app_settings", { - settings: { ...initialSettings, trash_retention_days: 7 }, - }); - assert.equal(savedSettings.trash_retention_days, 7); - assert.equal( - (await app.invoke("get_app_settings")).trash_retention_days, - 7, - ); - // Out-of-range values are clamped, never rejected. - const clamped = await app.invoke("save_app_settings", { - settings: { ...savedSettings, trash_retention_days: 9000 }, - }); - assert.equal(clamped.trash_retention_days, 365); - await app.invoke("save_app_settings", { - settings: { ...clamped, trash_retention_days: 7 }, - }); - assert.deepEqual(await app.invoke("list_trashed_profiles"), []); const group = await app.invoke("create_profile_group", { @@ -1157,7 +1138,7 @@ test("deleted profiles land in the trash and come back intact on restore", async assert.equal(trashed[0].password_protected, false); assert.equal( trashed[0].expires_at - trashed[0].deleted_at, - 7 * 24 * 60 * 60, + 30 * 24 * 60 * 60, ); assert.ok(trashed[0].size_bytes > 0); const trashDir = path.join(app.dataRoot, "data", "trash"); diff --git a/e2e/tests/smoke.test.mjs b/e2e/tests/smoke.test.mjs index 4b8e4947..26b65da7 100644 --- a/e2e/tests/smoke.test.mjs +++ b/e2e/tests/smoke.test.mjs @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; @@ -275,115 +274,3 @@ test("tray labels, hide-to-tray, and confirmed quit follow the native lifecycle" await app.close(); } }); - -test("the data directory can be moved to another folder and the choice survives a restart", async () => { - await withApp( - "smoke-data-root", - async (app) => { - // Every path below is inside this session's own temporary root. The - // real installation is never a source or a destination here. - const defaultRoot = path.join(app.dataRoot, "data"); - const pointerFile = path.join(app.dataRoot, "data-root.json"); - const destination = path.join(app.root, "moved-donut-data"); - - const before = await app.invoke("get_data_root_info"); - assert.equal(before.active_path, defaultRoot); - assert.equal(before.configured_path, null); - assert.equal(before.restart_required, false); - assert.equal(before.active_path_missing, false); - assert.equal(before.overridden_by_environment, false); - assert.ok(before.file_count > 0, "the seeded settings file is counted"); - assert.ok(before.size_bytes > 0, "the directory reports a real size"); - assert.equal(typeof before.app_directory_name, "string"); - - const profile = await app.invoke("create_browser_profile_new", { - name: "Carried Across", - browserStr: "wayfern", - version: "150.0.7871.100", - releaseType: "stable", - proxyId: null, - vpnId: null, - wayfernConfig: { fingerprint: "{}" }, - groupId: null, - ephemeral: false, - dnsBlocklist: null, - launchHook: null, - }); - - // Each refusal is its own code, because each one has a different fix. - assert.match( - await app.invokeError("move_data_root", { destination: defaultRoot }), - /DATA_ROOT_SAME_AS_CURRENT/, - ); - assert.match( - await app.invokeError("move_data_root", { - destination: path.join(defaultRoot, "profiles", "elsewhere"), - }), - /DATA_ROOT_DESTINATION_INSIDE_SOURCE/, - ); - assert.match( - await app.invokeError("move_data_root", { - destination: "not/absolute", - }), - /DATA_ROOT_DESTINATION_NOT_WRITABLE/, - ); - assert.equal( - existsSync(destination), - false, - "a refused move must not create the destination", - ); - - const moved = await app.invoke("move_data_root", { destination }); - assert.equal(moved.configured_path, destination); - assert.equal(moved.restart_required, true); - // The move takes effect at the next start: this process keeps every - // path it resolved when it started. - assert.equal(moved.active_path, defaultRoot); - - // Copy, then verify, then delete: the old directory only goes once the - // copy has been proven whole. - assert.equal(existsSync(defaultRoot), false, "the source is removed"); - assert.ok( - existsSync(path.join(destination, "settings", "app_settings.json")), - "settings travelled with the move", - ); - assert.ok( - existsSync(path.join(destination, "profiles")), - "profiles travelled with the move", - ); - - // The pointer lives beside the data directory, never inside it, or the - // delete above would have taken it and the next start would forget. - const pointer = JSON.parse(await readFile(pointerFile, "utf8")); - assert.equal(pointer.path, destination); - - await app.restart(); - - const after = await app.invoke("get_data_root_info"); - assert.equal(after.active_path, destination); - assert.equal(after.configured_path, destination); - assert.equal(after.restart_required, false); - assert.equal(after.active_path_missing, false); - - const profiles = await app.invoke("list_browser_profiles"); - assert.ok( - profiles.some((entry) => entry.id === profile.id), - "the moved directory still holds the profile", - ); - const settings = await app.invoke("get_app_settings"); - assert.equal(settings.onboarding_completed, true); - - // Forgetting the choice is the escape hatch for a drive that is gone - // for good; it moves nothing, so it too only lands on the next start. - const cleared = await app.invoke("clear_data_root_choice"); - assert.equal(cleared.configured_path, null); - assert.equal(existsSync(pointerFile), false); - - await app.restart(); - const restored = await app.invoke("get_data_root_info"); - assert.equal(restored.active_path, defaultRoot); - assert.equal(restored.configured_path, null); - }, - { seedDownloadedBrowser: true }, - ); -}); diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 9ca5ffb6..83a399a4 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -2150,138 +2150,6 @@ async function restoreStubs(app) { ); } -const DATA_ROOT = en.settings.dataRoot; - -test("the data directory setting shows where state lives, refuses a bad move, and reports the one it makes", async () => { - await withApp("ui-data-root", async (app) => { - const defaultRoot = path.join(app.dataRoot, "data"); - await app.clickSelector(`[aria-label="${en.rail.settings}"]`); - await app.waitFor( - () => - app.execute( - `return Boolean(document.querySelector('[data-slot="data-root-setting"]'));`, - ), - { description: "the data directory control" }, - ); - - const shown = await app.execute( - `return { - path: document.querySelector('[data-slot="data-root-active-path"]')?.innerText ?? null, - size: document.querySelector('[data-slot="data-root-size"]')?.innerText ?? null, - moveDisabled: document.querySelector('[data-slot="data-root-move"]')?.disabled ?? null, - missing: Boolean(document.querySelector('[data-slot="data-root-missing"]')), - restart: Boolean(document.querySelector('[data-slot="data-root-restart-required"]')), - };`, - ); - assert.equal(shown.path, defaultRoot, "the real directory is on screen"); - assert.match(shown.size, /\d/, "the size has to be a real figure"); - assert.equal( - shown.moveDisabled, - true, - "there is nowhere to move to until a folder is chosen", - ); - assert.equal(shown.missing, false); - assert.equal(shown.restart, false); - assert.equal(await app.visibleTextIncludes(DATA_ROOT.title), true); - - // A folder inside the current directory: the copy would never finish and - // the delete afterwards would take the copy with it. - await stubFolderPicker(app, path.join(defaultRoot, "profiles")); - await app.clickSelector('[data-slot="data-root-choose"]'); - await app.waitFor( - async () => - (await app.execute( - `return document.querySelector('[data-slot="data-root-move"]')?.disabled;`, - )) === false, - { description: "the move button waking up once a folder is chosen" }, - ); - const chosen = await app.execute( - `return document.querySelector('[data-slot="data-root-destination"]')?.innerText ?? null;`, - ); - assert.ok( - chosen?.startsWith(path.join(defaultRoot, "profiles")), - `the exact destination has to be shown before committing, got: ${chosen}`, - ); - - await app.clickSelector('[data-slot="data-root-move"]'); - await app.waitFor( - () => - app.execute( - `return Boolean(document.querySelector('[data-slot="data-root-error"]'));`, - ), - { description: "the refusal" }, - ); - assert.equal( - (await restoreFolderPicker(app)).length, - 1, - "the first choice went through the picker", - ); - assert.equal( - await app.execute( - `return document.querySelector('[data-slot="data-root-error"]').innerText.trim();`, - ), - en.backendErrors.dataRootDestinationInsideSource, - "a refusal is a translated sentence, never a raw code", - ); - await app.capture("data-root-refused"); - - // Nothing was created for a move that was never going to run. - assert.equal( - await app - .invoke("get_data_root_info") - .then((info) => info.configured_path), - null, - ); - - // Now a real move, into this session's own temporary root. - const destination = path.join(app.root, "ui-moved-data"); - await stubFolderPicker(app, destination); - await app.clickSelector('[data-slot="data-root-choose"]'); - await app.waitFor( - async () => - ( - await app.execute( - `return document.querySelector('[data-slot="data-root-destination"]')?.innerText ?? "";`, - ) - ).startsWith(destination), - { description: "the new destination on screen" }, - ); - await app.clickSelector('[data-slot="data-root-move"]'); - await app.waitFor( - () => - app.execute( - `return Boolean(document.querySelector('[data-slot="data-root-restart-required"]'));`, - ), - { description: "the restart notice", timeoutMs: 60_000 }, - ); - assert.equal( - await app.execute( - `return Boolean(document.querySelector('[data-slot="data-root-error"]'));`, - ), - false, - "a move that worked must not also report an error", - ); - await app.capture("data-root-moved"); - - // The fixture clears its own log each time it is installed, so this reads - // only the second choice. - const picks = await restoreFolderPicker(app); - assert.equal(picks.length, 1); - assert.equal( - picks[0]?.options?.directory, - true, - "the control asks for a folder, not a file", - ); - - const info = await app.invoke("get_data_root_info"); - assert.ok( - info.configured_path?.startsWith(destination), - `the choice has to be recorded, got: ${info.configured_path}`, - ); - assert.equal(info.restart_required, true); - }); -}); - const SYNC = en.profiles.synchronizer; test("the synchroniser panel lists a live session and its controls act on the real state", async () => { diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index eba19280..9346bc8e 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -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)) + } } } diff --git a/src-tauri/src/app_dirs.rs b/src-tauri/src/app_dirs.rs index cfcb399b..15123169 100644 --- a/src-tauri/src/app_dirs.rs +++ b/src-tauri/src/app_dirs.rs @@ -61,121 +61,10 @@ fn log_dir_for(root: Option, portable: Option<&PathBuf>) -> Option> = 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, `/data-root.json`, a sibling of -/// `/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, `/data-root.json`, beside `/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, - 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 { - 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, - custom_root: Option<&PathBuf>, - env_data_root: Option, - 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 /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")); diff --git a/src-tauri/src/data_root.rs b/src-tauri/src/data_root.rs deleted file mode 100644 index be03d8a7..00000000 --- a/src-tauri/src/data_root.rs +++ /dev/null @@ -1,1036 +0,0 @@ -//! Moving Donut's data directory to another volume. -//! -//! Everything the app keeps lives under `app_dirs::data_dir()`: profiles, -//! downloaded browser binaries, settings, proxies, VPNs and extensions. A fleet -//! outgrows a small system disk long before it outgrows the machine, so the -//! directory has to be movable without hand-editing anything. -//! -//! The move is **copy, verify, then delete**, in that order and never any -//! other. A `rename` across volumes is not atomic and can leave half a profile -//! at each end; a delete before the copy is proven can lose the only copy of a -//! logged-in profile. The pointer that decides which directory the next start -//! uses is written only after verification passes, so a process killed at any -//! point still starts on a directory that is whole. - -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; - -use serde::{Deserialize, Serialize}; -use tauri::Emitter; - -/// Emitted while a move runs so the page can show real progress. -pub const MOVE_PROGRESS_EVENT: &str = "data-root-move-progress"; - -/// Sample files compared byte for byte after the copy, on top of the file count -/// and total size. Enough to catch a truncating or silently-failing filesystem -/// without re-reading tens of gigabytes. -const VERIFY_SAMPLE_SIZE: usize = 12; - -/// How much of a sampled file is compared when it is too big to read whole. -/// The head and the tail together catch both a truncated write and a copy that -/// never started. -const SAMPLE_EDGE_BYTES: u64 = 1024 * 1024; -const SAMPLE_WHOLE_FILE_LIMIT: u64 = 4 * 1024 * 1024; - -/// One move at a time. Two concurrent moves would interleave two copies into -/// one destination and then race to delete the same source. -static MOVE_RUNNING: AtomicBool = AtomicBool::new(false); - -/// Set once a move succeeds. The running process keeps using the old directory -/// (every path was resolved at startup), so the page has to say so out loud. -static RESTART_REQUIRED: AtomicBool = AtomicBool::new(false); - -fn code(code: &str) -> String { - serde_json::json!({ "code": code }).to_string() -} - -fn code_with(code: &str, params: serde_json::Value) -> String { - serde_json::json!({ "code": code, "params": params }).to_string() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DataRootInfo { - /// The directory this process is actually using. - pub active_path: String, - /// The directory recorded for the next start, when one was chosen. - pub configured_path: Option, - /// Where the directory would resolve with nothing chosen. - pub default_path: String, - /// Bytes under `active_path`. - pub size_bytes: u64, - /// Regular files under `active_path`. - pub file_count: u64, - /// True when `DONUTBROWSER_DATA_DIR` decides the directory, so a choice made - /// here would be recorded and then ignored. - pub overridden_by_environment: bool, - /// True once a move has completed in this process. - pub restart_required: bool, - /// The folder name a destination gets, so the page can show the full path it - /// is about to move to before the user commits. - pub app_directory_name: String, - /// The recorded directory is not there right now, which is what an - /// unplugged external drive looks like. - /// - /// The app deliberately keeps pointing at it rather than quietly starting - /// empty somewhere else: plugging the drive back in has to restore - /// everything, and a silent fallback is how a person concludes their - /// profiles are gone. - /// - /// Never true straight after a move. The old directory is *supposed* to be - /// gone then, and reporting that as a fault would tell somebody their move - /// had broken something the moment it succeeded. - pub active_path_missing: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MoveProgress { - /// `scanning`, `copying`, `verifying`, `cleaning` or `done`. - pub phase: String, - pub copied_files: u64, - pub total_files: u64, - pub copied_bytes: u64, - pub total_bytes: u64, - pub destination: String, -} - -/// What a walk of the source found. `bytes` counts regular files only: -/// directories and symlinks have a size that means nothing here and would make -/// the free-space estimate and the verification disagree across platforms. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub(crate) struct TreeScan { - pub files: u64, - pub bytes: u64, - pub directories: u64, - pub symlinks: u64, -} - -/// The facts a refusal is decided from, gathered before anything is copied. -pub(crate) struct MovePreconditions<'a> { - pub source: &'a Path, - pub destination: &'a Path, - pub browser_running: bool, - pub sync_in_progress: bool, - pub required_bytes: u64, - pub available_bytes: u64, -} - -/// Every refusal that can be decided without touching the disk. -/// -/// Ordered by how much the user can do about it: a destination that is the -/// current directory, or sits inside it, is a mistake in the request itself; -/// a running browser or a live sync is a "not now"; space is last because it -/// is the only one that needs the source measured first. -pub(crate) fn check_move_preconditions(p: &MovePreconditions) -> Result<(), String> { - if paths_equal(p.source, p.destination) { - return Err(code("DATA_ROOT_SAME_AS_CURRENT")); - } - if is_inside(p.destination, p.source) { - // Copying a directory into itself never terminates, and deleting the - // source afterwards would delete the copy with it. - return Err(code("DATA_ROOT_DESTINATION_INSIDE_SOURCE")); - } - if p.browser_running { - return Err(code("DATA_ROOT_BROWSER_RUNNING")); - } - if p.sync_in_progress { - return Err(code("DATA_ROOT_SYNC_IN_PROGRESS")); - } - if p.available_bytes < p.required_bytes { - return Err(code_with( - "DATA_ROOT_INSUFFICIENT_SPACE", - serde_json::json!({ - "required": human_bytes(p.required_bytes), - "available": human_bytes(p.available_bytes), - }), - )); - } - Ok(()) -} - -/// Bytes as a person reads them, for the one refusal that has to quote a size. -/// -/// The unit symbols are the same in every language Donut ships, so the sentence -/// around them is translated and the figure is not. It is written here rather -/// than in the frontend because `backend-errors.ts` is loaded by a bare -/// `node --test` run and cannot import anything of ours. -pub(crate) fn human_bytes(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = KB * 1024; - const GB: u64 = MB * 1024; - if bytes < KB { - return format!("{bytes} B"); - } - if bytes < MB { - return format!("{:.1} KB", bytes as f64 / KB as f64); - } - if bytes < GB { - return format!("{:.1} MB", bytes as f64 / MB as f64); - } - format!("{:.2} GB", bytes as f64 / GB as f64) -} - -/// Compare two paths without needing either to exist. `canonicalize` is used -/// when it works (it resolves `..`, symlinks and case on macOS), and the -/// lexical form is the fallback for a destination that has not been created. -fn paths_equal(a: &Path, b: &Path) -> bool { - match (a.canonicalize(), b.canonicalize()) { - (Ok(a), Ok(b)) => a == b, - _ => normalized(a) == normalized(b), - } -} - -/// True when `inner` is `outer` itself or sits below it. -pub(crate) fn is_inside(inner: &Path, outer: &Path) -> bool { - let (inner, outer) = match (inner.canonicalize(), outer.canonicalize()) { - (Ok(i), Ok(o)) => (i, o), - _ => (normalized(inner), normalized(outer)), - }; - inner.starts_with(&outer) -} - -/// Lexical `..`/`.` removal, so `/a/b/../b/c` and `/a/b/c` compare equal even -/// when neither exists yet. -fn normalized(path: &Path) -> PathBuf { - use std::path::Component; - let mut out = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - out.pop(); - } - other => out.push(other.as_os_str()), - } - } - out -} - -/// Prove the destination can be written to before a single byte is copied. -/// Creating the directory is part of the probe: a parent that refuses `mkdir` -/// is exactly as unusable as one that refuses a write. -pub(crate) fn probe_writable(destination: &Path) -> Result<(), String> { - if let Err(e) = std::fs::create_dir_all(destination) { - log::warn!( - "Cannot use {} as a data directory: {e}", - destination.display() - ); - return Err(code("DATA_ROOT_DESTINATION_NOT_WRITABLE")); - } - let probe = destination.join(".donut-write-probe"); - match std::fs::write(&probe, b"donut") { - Ok(()) => { - let _ = std::fs::remove_file(&probe); - Ok(()) - } - Err(e) => { - log::warn!("Cannot write inside {}: {e}", destination.display()); - Err(code("DATA_ROOT_DESTINATION_NOT_WRITABLE")) - } - } -} - -/// Refuse a destination that already holds files. Merging into somebody's -/// folder makes the count-and-size verification meaningless and makes the -/// delete that follows impossible to reason about. -pub(crate) fn ensure_empty(destination: &Path) -> Result<(), String> { - let Ok(entries) = std::fs::read_dir(destination) else { - return Ok(()); - }; - for entry in entries.flatten() { - if entry.file_name() == ".donut-write-probe" { - continue; - } - return Err(code("DATA_ROOT_DESTINATION_NOT_EMPTY")); - } - Ok(()) -} - -/// Walk a tree, counting regular files, their bytes, directories and symlinks. -/// -/// Symlinks are counted but never followed: a link out of the data directory -/// would pull unrelated data into the copy, and a link back into it would loop. -pub(crate) fn scan_tree(root: &Path) -> std::io::Result { - let mut scan = TreeScan::default(); - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(&dir)? { - let entry = entry?; - let path = entry.path(); - let meta = std::fs::symlink_metadata(&path)?; - if meta.file_type().is_symlink() { - scan.symlinks += 1; - } else if meta.is_dir() { - scan.directories += 1; - stack.push(path); - } else { - scan.files += 1; - scan.bytes += meta.len(); - } - } - } - Ok(scan) -} - -/// Free bytes on the volume holding `path`, found by the longest mount point -/// that is a prefix of it. `None` when no mount point matches, which is not a -/// reason to refuse a move: an unknown figure is not a small one. -pub(crate) fn available_space(path: &Path) -> Option { - let disks = sysinfo::Disks::new_with_refreshed_list(); - let target = normalized(path); - let mut best: Option<(usize, u64)> = None; - for disk in disks.list() { - let mount = disk.mount_point(); - if !target.starts_with(mount) { - continue; - } - let depth = mount.components().count(); - if best.is_none_or(|(previous, _)| depth > previous) { - best = Some((depth, disk.available_space())); - } - } - best.map(|(_, free)| free) -} - -/// Copy `source` into `destination`, reporting progress and collecting the -/// sample the verification re-reads. -fn copy_tree( - source: &Path, - destination: &Path, - total: &TreeScan, - report: &mut dyn FnMut(&str, u64, u64), -) -> std::io::Result> { - let stride = (total.files / VERIFY_SAMPLE_SIZE as u64).max(1); - let mut samples: Vec = Vec::new(); - let mut copied_files = 0u64; - let mut copied_bytes = 0u64; - let mut stack = vec![PathBuf::new()]; - - while let Some(relative) = stack.pop() { - let from = source.join(&relative); - let to = destination.join(&relative); - std::fs::create_dir_all(&to)?; - for entry in std::fs::read_dir(&from)? { - let entry = entry?; - let name = entry.file_name(); - let child = relative.join(&name); - let path = entry.path(); - let meta = std::fs::symlink_metadata(&path)?; - if meta.file_type().is_symlink() { - copy_symlink(&path, &destination.join(&child))?; - } else if meta.is_dir() { - stack.push(child); - } else { - std::fs::copy(&path, destination.join(&child))?; - copied_files += 1; - copied_bytes += meta.len(); - if copied_files.is_multiple_of(stride) && samples.len() < VERIFY_SAMPLE_SIZE { - samples.push(child); - } - if copied_files.is_multiple_of(200) { - report("copying", copied_files, copied_bytes); - } - } - } - } - report("copying", copied_files, copied_bytes); - Ok(samples) -} - -/// Recreate a symlink at the destination rather than following it. -#[cfg(unix)] -fn copy_symlink(from: &Path, to: &Path) -> std::io::Result<()> { - let target = std::fs::read_link(from)?; - if to.exists() { - let _ = std::fs::remove_file(to); - } - std::os::unix::fs::symlink(target, to) -} - -#[cfg(windows)] -fn copy_symlink(from: &Path, to: &Path) -> std::io::Result<()> { - // Creating a symlink on Windows needs a privilege a normal user does not - // have, so the link's contents are copied instead. The entry still exists at - // the same path, which is what the verification checks. - let metadata = std::fs::metadata(from)?; - if metadata.is_dir() { - std::fs::create_dir_all(to) - } else { - std::fs::copy(from, to).map(|_| ()) - } -} - -/// Compare a copied file with its source. Whole files up to -/// `SAMPLE_WHOLE_FILE_LIMIT`; head and tail beyond that, so a 4 GB browser -/// archive is still checked at both ends without being read twice over. -fn same_contents(a: &Path, b: &Path) -> std::io::Result { - use std::io::{Read, Seek, SeekFrom}; - let mut left = std::fs::File::open(a)?; - let mut right = std::fs::File::open(b)?; - let left_len = left.metadata()?.len(); - let right_len = right.metadata()?.len(); - if left_len != right_len { - return Ok(false); - } - if left_len <= SAMPLE_WHOLE_FILE_LIMIT { - let mut left_buf = Vec::new(); - let mut right_buf = Vec::new(); - left.read_to_end(&mut left_buf)?; - right.read_to_end(&mut right_buf)?; - return Ok(left_buf == right_buf); - } - let mut left_buf = vec![0u8; SAMPLE_EDGE_BYTES as usize]; - let mut right_buf = vec![0u8; SAMPLE_EDGE_BYTES as usize]; - for offset in [0, left_len - SAMPLE_EDGE_BYTES] { - left.seek(SeekFrom::Start(offset))?; - right.seek(SeekFrom::Start(offset))?; - left.read_exact(&mut left_buf)?; - right.read_exact(&mut right_buf)?; - if left_buf != right_buf { - return Ok(false); - } - } - Ok(true) -} - -/// Prove the copy is complete before anything is deleted. -/// -/// The counts and the total size catch a lost or truncated file; re-reading the -/// sample catches a filesystem that reported a write it never made. -pub(crate) fn verify_copy( - source: &Path, - destination: &Path, - expected: &TreeScan, - samples: &[PathBuf], -) -> Result<(), String> { - let copied = scan_tree(destination).map_err(|e| { - log::error!("Could not read the copy at {}: {e}", destination.display()); - code_with( - "DATA_ROOT_VERIFY_FAILED", - serde_json::json!({ "detail": e.to_string() }), - ) - })?; - - if copied.files != expected.files || copied.bytes != expected.bytes { - log::error!( - "The copy at {} does not match {}: {} files / {} bytes against {} files / {} bytes", - destination.display(), - source.display(), - copied.files, - copied.bytes, - expected.files, - expected.bytes - ); - return Err(code_with( - "DATA_ROOT_VERIFY_FAILED", - serde_json::json!({ - "expectedFiles": expected.files.to_string(), - "copiedFiles": copied.files.to_string(), - "expectedBytes": expected.bytes.to_string(), - "copiedBytes": copied.bytes.to_string(), - }), - )); - } - - for relative in samples { - let from = source.join(relative); - let to = destination.join(relative); - match same_contents(&from, &to) { - Ok(true) => {} - Ok(false) => { - log::error!("{} did not copy faithfully", relative.display()); - return Err(code_with( - "DATA_ROOT_VERIFY_FAILED", - serde_json::json!({ "detail": relative.to_string_lossy() }), - )); - } - Err(e) => { - log::error!("Could not re-read {}: {e}", relative.display()); - return Err(code_with( - "DATA_ROOT_VERIFY_FAILED", - serde_json::json!({ "detail": e.to_string() }), - )); - } - } - } - Ok(()) -} - -/// True when any profile still has a live process. -fn any_browser_running() -> bool { - let Ok(profiles) = crate::profile::ProfileManager::instance().list_profiles() else { - // Unreadable profiles means an unknown answer, and an unknown answer must - // not clear the way for a move that deletes them. - log::warn!("Could not list profiles before a data directory move; assuming one is running"); - return true; - }; - profiles.into_iter().any(|profile| { - profile - .process_id - .is_some_and(|pid| pid != 0 && crate::proxy_storage::is_process_running(pid)) - }) -} - -async fn sync_in_progress() -> bool { - match crate::sync::get_global_scheduler() { - Some(scheduler) => scheduler.is_sync_in_progress().await, - None => false, - } -} - -fn info_now() -> DataRootInfo { - let active = crate::app_dirs::data_dir(); - let scan = scan_tree(&active).unwrap_or_default(); - let restart_required = RESTART_REQUIRED.load(Ordering::SeqCst); - DataRootInfo { - active_path_missing: !restart_required && !active.is_dir(), - active_path: active.to_string_lossy().to_string(), - configured_path: crate::app_dirs::read_data_root_pointer( - &crate::app_dirs::data_root_pointer_file(), - ) - .map(|path| path.to_string_lossy().to_string()), - default_path: crate::app_dirs::default_data_dir() - .to_string_lossy() - .to_string(), - size_bytes: scan.bytes, - file_count: scan.files, - overridden_by_environment: crate::app_dirs::data_dir_forced_by_environment(), - restart_required, - app_directory_name: crate::app_dirs::app_name().to_string(), - } -} - -/// Release the one-move-at-a-time flag however the move ends. -struct MoveGuard; - -impl Drop for MoveGuard { - fn drop(&mut self) { - MOVE_RUNNING.store(false, Ordering::SeqCst); - } -} - -async fn move_to( - app_handle: tauri::AppHandle, - destination: PathBuf, -) -> Result { - if MOVE_RUNNING.swap(true, Ordering::SeqCst) { - return Err(code("DATA_ROOT_MOVE_IN_PROGRESS")); - } - let _guard = MoveGuard; - - if !destination.is_absolute() || destination.as_os_str().is_empty() { - return Err(code("DATA_ROOT_DESTINATION_NOT_WRITABLE")); - } - - // The one fact that has to be read from the async side; everything after it - // is filesystem work. - let sync_running = sync_in_progress().await; - - // Copying a fleet is minutes of blocking IO. Left on a runtime worker it - // would stall every other task in the app — proxy workers, the sync - // scheduler, the event loop that carries the progress this very move emits. - match tokio::task::spawn_blocking(move || perform_move(app_handle, destination, sync_running)) - .await - { - Ok(result) => result, - Err(e) => { - log::error!("The data directory move task did not finish: {e}"); - Err(code_with( - "DATA_ROOT_COPY_FAILED", - serde_json::json!({ "detail": e.to_string() }), - )) - } - } -} - -/// The move itself, start to finish, on a blocking thread. -fn perform_move( - app_handle: tauri::AppHandle, - destination: PathBuf, - sync_running: bool, -) -> Result { - let source = crate::app_dirs::data_dir(); - let emit = |phase: &str, copied_files: u64, copied_bytes: u64, total: &TreeScan| { - let _ = app_handle.emit( - MOVE_PROGRESS_EVENT, - MoveProgress { - phase: phase.to_string(), - copied_files, - total_files: total.files, - copied_bytes, - total_bytes: total.bytes, - destination: destination.to_string_lossy().to_string(), - }, - ); - }; - - emit("scanning", 0, 0, &TreeScan::default()); - - let total = scan_tree(&source).map_err(|e| { - log::error!("Could not measure {}: {e}", source.display()); - code_with( - "DATA_ROOT_COPY_FAILED", - serde_json::json!({ "detail": e.to_string() }), - ) - })?; - - // The refusals that need no disk write come first, so a destination that was - // never going to be used is not created as a side effect of asking. - check_move_preconditions(&MovePreconditions { - source: &source, - destination: &destination, - browser_running: any_browser_running(), - sync_in_progress: sync_running, - required_bytes: total.bytes, - // An unreadable volume is not a full one: skip the check rather than - // refuse a move that would have worked. - available_bytes: available_space(&destination).unwrap_or(u64::MAX), - })?; - - probe_writable(&destination)?; - ensure_empty(&destination)?; - - let mut report = |phase: &str, files: u64, bytes: u64| emit(phase, files, bytes, &total); - - let samples = copy_tree(&source, &destination, &total, &mut report).map_err(|e| { - log::error!( - "Copying {} to {} failed: {e}", - source.display(), - destination.display() - ); - code_with( - "DATA_ROOT_COPY_FAILED", - serde_json::json!({ "detail": e.to_string() }), - ) - })?; - - emit("verifying", total.files, total.bytes, &total); - verify_copy(&source, &destination, &total, &samples)?; - - // Only now, with the copy proven whole, does the next start change where it - // looks. A process killed before this line still starts on the old directory. - crate::app_dirs::write_data_root_pointer( - &crate::app_dirs::data_root_pointer_file(), - &destination, - ) - .map_err(|e| { - log::error!("Could not record the new data directory: {e}"); - code_with( - "DATA_ROOT_COPY_FAILED", - serde_json::json!({ "detail": e.to_string() }), - ) - })?; - RESTART_REQUIRED.store(true, Ordering::SeqCst); - - emit("cleaning", total.files, total.bytes, &total); - if let Err(e) = std::fs::remove_dir_all(&source) { - // The move already succeeded: the pointer is written and the copy is - // verified. A source that will not delete is leftover disk, not a failure. - log::warn!( - "Moved the data directory to {} but could not remove {}: {e}", - destination.display(), - source.display() - ); - } - - emit("done", total.files, total.bytes, &total); - log::info!( - "Data directory moved to {} ({} files, {} bytes); it takes effect on the next start", - destination.display(), - total.files, - total.bytes - ); - Ok(info_now()) -} - -// --- Tauri commands --- - -#[tauri::command] -pub async fn get_data_root_info() -> Result { - // Walking a fleet's worth of profiles is not work for the UI thread. A join - // failure means the pool is gone, not that the answer is unknowable, so the - // same read runs here rather than inventing a second, emptier answer. - match tokio::task::spawn_blocking(info_now).await { - Ok(info) => Ok(info), - Err(e) => { - log::error!("Reading the data directory off-thread failed: {e}"); - Ok(info_now()) - } - } -} - -#[tauri::command] -pub async fn move_data_root( - app_handle: tauri::AppHandle, - destination: String, -) -> Result { - move_to(app_handle, PathBuf::from(destination)).await -} - -/// Forget a recorded directory so the next start uses the platform default -/// again. -/// -/// It moves nothing. The page offers it only when the recorded directory is -/// not there — an external drive that is gone for good — because that is the -/// one case where pointing at it is worse than starting fresh. -#[tauri::command] -pub async fn clear_data_root_choice() -> Result { - crate::app_dirs::clear_data_root_pointer(&crate::app_dirs::data_root_pointer_file()).map_err( - |e| { - log::error!("Could not clear the data directory choice: {e}"); - code_with( - "DATA_ROOT_COPY_FAILED", - serde_json::json!({ "detail": e.to_string() }), - ) - }, - )?; - RESTART_REQUIRED.store(true, Ordering::SeqCst); - Ok(info_now()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parsed_code(err: &str) -> String { - serde_json::from_str::(err) - .expect("errors are JSON") - .get("code") - .and_then(|c| c.as_str()) - .expect("errors carry a code") - .to_string() - } - - fn baseline<'a>(source: &'a Path, destination: &'a Path) -> MovePreconditions<'a> { - MovePreconditions { - source, - destination, - browser_running: false, - sync_in_progress: false, - required_bytes: 100, - available_bytes: 1000, - } - } - - fn write(path: &Path, bytes: &[u8]) { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, bytes).unwrap(); - } - - #[test] - fn a_clean_request_is_allowed() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - std::fs::create_dir_all(&source).unwrap(); - assert!(check_move_preconditions(&baseline(&source, &destination)).is_ok()); - } - - #[test] - fn a_running_browser_stops_the_move() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - std::fs::create_dir_all(&source).unwrap(); - let mut p = baseline(&source, &destination); - p.browser_running = true; - assert_eq!( - parsed_code(&check_move_preconditions(&p).unwrap_err()), - "DATA_ROOT_BROWSER_RUNNING" - ); - } - - #[test] - fn a_live_sync_stops_the_move() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - std::fs::create_dir_all(&source).unwrap(); - let mut p = baseline(&source, &destination); - p.sync_in_progress = true; - assert_eq!( - parsed_code(&check_move_preconditions(&p).unwrap_err()), - "DATA_ROOT_SYNC_IN_PROGRESS" - ); - } - - #[test] - fn a_destination_inside_the_source_is_refused() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - std::fs::create_dir_all(source.join("profiles")).unwrap(); - - for inside in [ - source.join("profiles"), - source.join("deep").join("nested"), - source.join("profiles").join("..").join("binaries"), - ] { - assert_eq!( - parsed_code(&check_move_preconditions(&baseline(&source, &inside)).unwrap_err()), - "DATA_ROOT_DESTINATION_INSIDE_SOURCE", - "{} is inside {}", - inside.display(), - source.display() - ); - } - - // The source itself is its own refusal: nothing to copy, and the delete - // that follows would take the only copy with it. - assert_eq!( - parsed_code(&check_move_preconditions(&baseline(&source, &source)).unwrap_err()), - "DATA_ROOT_SAME_AS_CURRENT" - ); - - // A sibling that merely shares a prefix is not inside it. - let sibling = temp.path().join("source-elsewhere"); - assert!(check_move_preconditions(&baseline(&source, &sibling)).is_ok()); - } - - #[test] - fn a_destination_with_less_space_than_the_source_needs_is_refused() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - std::fs::create_dir_all(&source).unwrap(); - - let mut p = baseline(&source, &destination); - p.required_bytes = 8 * 1024 * 1024 * 1024; - p.available_bytes = 1024 * 1024 * 1024; - let err = check_move_preconditions(&p).unwrap_err(); - assert_eq!(parsed_code(&err), "DATA_ROOT_INSUFFICIENT_SPACE"); - let value: serde_json::Value = serde_json::from_str(&err).unwrap(); - // Written out, because the sentence is shown to a person. - assert_eq!(value["params"]["required"], "8.00 GB"); - assert_eq!(value["params"]["available"], "1.00 GB"); - - // Exactly enough is enough; one byte short is not. - p.available_bytes = p.required_bytes; - assert!(check_move_preconditions(&p).is_ok()); - p.available_bytes = p.required_bytes - 1; - assert_eq!( - parsed_code(&check_move_preconditions(&p).unwrap_err()), - "DATA_ROOT_INSUFFICIENT_SPACE" - ); - } - - #[cfg(unix)] - #[test] - fn an_unwritable_destination_is_refused() { - use std::os::unix::fs::PermissionsExt; - let temp = tempfile::tempdir().unwrap(); - let parent = temp.path().join("read-only"); - std::fs::create_dir_all(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap(); - - let destination = parent.join("DonutBrowser"); - let outcome = probe_writable(&destination); - // Root ignores the mode bits, so the probe legitimately succeeds there and - // there is nothing for this test to assert. - if outcome.is_ok() { - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); - return; - } - assert_eq!( - parsed_code(&outcome.unwrap_err()), - "DATA_ROOT_DESTINATION_NOT_WRITABLE" - ); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); - - // A writable destination passes, and the probe leaves nothing behind. - let fine = temp.path().join("fine"); - assert!(probe_writable(&fine).is_ok()); - assert_eq!(std::fs::read_dir(&fine).unwrap().count(), 0); - } - - #[test] - fn a_destination_that_already_holds_files_is_refused() { - let temp = tempfile::tempdir().unwrap(); - let destination = temp.path().join("destination"); - std::fs::create_dir_all(&destination).unwrap(); - assert!(ensure_empty(&destination).is_ok()); - write(&destination.join("someone-elses.txt"), b"hello"); - assert_eq!( - parsed_code(&ensure_empty(&destination).unwrap_err()), - "DATA_ROOT_DESTINATION_NOT_EMPTY" - ); - } - - #[test] - fn scanning_counts_files_bytes_and_directories_without_following_links() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("root"); - write(&root.join("a.txt"), b"12345"); - write(&root.join("nested").join("b.bin"), &[7u8; 40]); - std::fs::create_dir_all(root.join("empty")).unwrap(); - - let scan = scan_tree(&root).unwrap(); - assert_eq!(scan.files, 2); - assert_eq!(scan.bytes, 45); - assert_eq!(scan.directories, 2); - assert_eq!(scan.symlinks, 0); - - #[cfg(unix)] - { - // A link out of the tree must not drag its target's bytes in. - let outside = temp.path().join("outside.bin"); - std::fs::write(&outside, [1u8; 4096]).unwrap(); - std::os::unix::fs::symlink(&outside, root.join("link")).unwrap(); - let with_link = scan_tree(&root).unwrap(); - assert_eq!(with_link.files, 2); - assert_eq!(with_link.bytes, 45); - assert_eq!(with_link.symlinks, 1); - } - } - - #[test] - fn verification_accepts_a_faithful_copy() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - write(&source.join("settings").join("app.json"), b"{\"a\":1}"); - write( - &source.join("profiles").join("one").join("Cookies"), - &[3u8; 900], - ); - write(&source.join("binaries").join("browser"), &[9u8; 2048]); - - let total = scan_tree(&source).unwrap(); - let mut noop = |_: &str, _: u64, _: u64| {}; - let samples = copy_tree(&source, &destination, &total, &mut noop).unwrap(); - assert!(!samples.is_empty(), "a sample must be collected to verify"); - assert!(verify_copy(&source, &destination, &total, &samples).is_ok()); - } - - #[test] - fn verification_rejects_a_copy_that_lost_a_file() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - write(&source.join("keep.txt"), b"kept"); - write( - &source.join("profiles").join("one").join("Login Data"), - &[5u8; 512], - ); - - let total = scan_tree(&source).unwrap(); - let mut noop = |_: &str, _: u64, _: u64| {}; - let samples = copy_tree(&source, &destination, &total, &mut noop).unwrap(); - - // Exactly the failure a delete-before-verify would turn into data loss. - std::fs::remove_file(destination.join("profiles").join("one").join("Login Data")).unwrap(); - assert_eq!( - parsed_code(&verify_copy(&source, &destination, &total, &samples).unwrap_err()), - "DATA_ROOT_VERIFY_FAILED" - ); - } - - #[test] - fn verification_rejects_a_copy_that_lost_bytes() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - write( - &source.join("profiles").join("one").join("History"), - &[4u8; 4096], - ); - - let total = scan_tree(&source).unwrap(); - let mut noop = |_: &str, _: u64, _: u64| {}; - let samples = copy_tree(&source, &destination, &total, &mut noop).unwrap(); - - std::fs::write( - destination.join("profiles").join("one").join("History"), - [4u8; 2048], - ) - .unwrap(); - assert_eq!( - parsed_code(&verify_copy(&source, &destination, &total, &samples).unwrap_err()), - "DATA_ROOT_VERIFY_FAILED" - ); - } - - #[test] - fn verification_rejects_a_file_whose_contents_changed() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - write(&source.join("only.bin"), &[1u8; 64]); - - let total = scan_tree(&source).unwrap(); - let mut noop = |_: &str, _: u64, _: u64| {}; - let samples = copy_tree(&source, &destination, &total, &mut noop).unwrap(); - assert_eq!(samples.len(), 1); - - // Same length, different bytes: only re-reading the sample catches this. - std::fs::write(destination.join("only.bin"), [2u8; 64]).unwrap(); - assert_eq!( - parsed_code(&verify_copy(&source, &destination, &total, &samples).unwrap_err()), - "DATA_ROOT_VERIFY_FAILED" - ); - } - - #[test] - fn a_copy_carries_every_nested_file_and_empty_directory() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - for i in 0..40 { - write( - &source - .join("profiles") - .join(format!("p{i}")) - .join("Cookies"), - &[i as u8; 64], - ); - } - std::fs::create_dir_all(source.join("extensions")).unwrap(); - - let total = scan_tree(&source).unwrap(); - let mut noop = |_: &str, _: u64, _: u64| {}; - let samples = copy_tree(&source, &destination, &total, &mut noop).unwrap(); - - assert_eq!(samples.len(), VERIFY_SAMPLE_SIZE); - assert_eq!(scan_tree(&destination).unwrap(), total); - assert!(destination.join("extensions").is_dir()); - assert!(verify_copy(&source, &destination, &total, &samples).is_ok()); - } - - #[test] - fn progress_reaches_the_full_count() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); - for i in 0..5 { - write(&source.join(format!("f{i}")), &[0u8; 10]); - } - let total = scan_tree(&source).unwrap(); - let mut seen: Vec<(String, u64, u64)> = Vec::new(); - let mut record = |phase: &str, files: u64, bytes: u64| { - seen.push((phase.to_string(), files, bytes)); - }; - copy_tree(&source, &destination, &total, &mut record).unwrap(); - let last = seen.last().expect("progress is reported at least once"); - assert_eq!(last.0, "copying"); - assert_eq!(last.1, total.files); - assert_eq!(last.2, total.bytes); - } - - #[test] - fn sizes_are_written_the_way_a_person_reads_them() { - assert_eq!(human_bytes(0), "0 B"); - assert_eq!(human_bytes(999), "999 B"); - assert_eq!(human_bytes(1024), "1.0 KB"); - assert_eq!(human_bytes(1536), "1.5 KB"); - assert_eq!(human_bytes(5 * 1024 * 1024), "5.0 MB"); - assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.00 GB"); - } - - #[test] - fn available_space_is_read_for_a_real_directory() { - let temp = tempfile::tempdir().unwrap(); - // The figure itself depends on the machine; that it resolves at all is - // what the refusal relies on. - if let Some(free) = available_space(temp.path()) { - assert!(free > 0); - } - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d49d164..bd0ac0ae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 296e81d4..0a708e6b 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -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 { diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 997126a7..bd20a18c 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -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(), )?; } diff --git a/src-tauri/src/profile/trash.rs b/src-tauri/src/profile/trash.rs index 107fb77b..c01b3efd 100644 --- a/src-tauri/src/profile/trash.rs +++ b/src-tauri/src/profile/trash.rs @@ -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 { 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(); diff --git a/src-tauri/src/profile_generation_limiter.rs b/src-tauri/src/profile_generation_limiter.rs new file mode 100644 index 00000000..16477aa2 --- /dev/null +++ b/src-tauri/src/profile_generation_limiter.rs @@ -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>, +} + +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> = + 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 + ); + } + } +} diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 180b9745..fff0e063 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -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] diff --git a/src-tauri/src/wayfern_identity_storage.rs b/src-tauri/src/wayfern_identity_storage.rs index dd01bc4b..c51d1859 100644 --- a/src-tauri/src/wayfern_identity_storage.rs +++ b/src-tauri/src/wayfern_identity_storage.rs @@ -343,7 +343,11 @@ pub async fn convert_for_launch(profile: &BrowserProfile) -> Result Result { - 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, + current: BrowserProfile, +) -> Result { 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 = diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index b241aef2..c0c34a94 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -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 { - static FONTS: std::sync::OnceLock> = - 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 { - 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> { - 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('&', "&") - .replace('<', "<") - .replace('>', ">"); - let svg = format!( - r##" - -{escaped} -"## - ); - 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 { - 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(); diff --git a/src/app/page.tsx b/src/app/page.tsx index ccce752d..efde20cd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2048,6 +2048,7 @@ export default function Home() { let unlistenProgress: (() => void) | undefined; let unlistenCompleted: (() => void) | undefined; let unlistenWayfernBlocked: (() => void) | undefined; + let unlistenGenerationLimit: (() => void) | undefined; let unlistenMcpLocalDeprecated: (() => void) | undefined; let unlistenMcpLocalMigrated: (() => void) | undefined; @@ -2112,6 +2113,19 @@ export default function Home() { }); }); + unlistenGenerationLimit = await listen( + "profile-generation-limit-reached", + () => { + showToast({ + id: "profile-generation-limit-reached", + type: "error", + title: t("profileGenerationLimit.title"), + description: t("profileGenerationLimit.description"), + duration: 15000, + }); + }, + ); + unlistenWayfernBlocked = await listen("wayfern-paid-blocked", () => { showToast({ id: "wayfern-paid-blocked", @@ -2160,6 +2174,7 @@ export default function Home() { unlistenProgress?.(); unlistenCompleted?.(); unlistenWayfernBlocked?.(); + unlistenGenerationLimit?.(); unlistenMcpLocalDeprecated?.(); unlistenMcpLocalMigrated?.(); } @@ -2172,6 +2187,7 @@ export default function Home() { unlistenProgress?.(); unlistenCompleted?.(); unlistenWayfernBlocked?.(); + unlistenGenerationLimit?.(); unlistenMcpLocalDeprecated?.(); unlistenMcpLocalMigrated?.(); }; diff --git a/src/components/data-root-setting.tsx b/src/components/data-root-setting.tsx deleted file mode 100644 index 4bc6f6f9..00000000 --- a/src/components/data-root-setting.tsx +++ /dev/null @@ -1,276 +0,0 @@ -"use client"; - -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; -import { open } from "@tauri-apps/plugin-dialog"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { LoadingButton } from "@/components/loading-button"; -import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; -import { translateBackendError } from "@/lib/backend-errors"; -import { formatBytes } from "@/lib/format-bytes"; -import { showSuccessToast } from "@/lib/toast-utils"; -import type { DataRootInfo, DataRootMoveProgress } from "@/types"; - -/** Join a chosen folder with the app's own folder name, both separator styles. */ -function resolveDestination(folder: string, appDirectoryName: string): string { - const separator = folder.includes("\\") && !folder.includes("/") ? "\\" : "/"; - const trimmed = folder.replace(/[\\/]+$/, ""); - return `${trimmed}${separator}${appDirectoryName}`; -} - -function percentOf(progress: DataRootMoveProgress): number { - if (progress.phase === "scanning") return 0; - if (progress.phase !== "copying") return 100; - if (progress.total_bytes > 0) { - return Math.min( - 100, - Math.round((progress.copied_bytes / progress.total_bytes) * 100), - ); - } - if (progress.total_files > 0) { - return Math.min( - 100, - Math.round((progress.copied_files / progress.total_files) * 100), - ); - } - return 0; -} - -export function DataRootSetting() { - const { t } = useTranslation(); - const [info, setInfo] = useState(null); - const [destination, setDestination] = useState(null); - const [progress, setProgress] = useState(null); - const [error, setError] = useState(null); - const [isMoving, setIsMoving] = useState(false); - const movePending = useRef(false); - - const load = useCallback(async () => { - try { - setInfo(await invoke("get_data_root_info")); - } catch (err) { - console.error("Failed to read the data directory:", err); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - let unlisten: (() => void) | undefined; - void listen("data-root-move-progress", (event) => { - setProgress(event.payload); - }).then((stop) => { - unlisten = stop; - }); - return () => { - unlisten?.(); - }; - }, []); - - const handleChoose = useCallback(async () => { - if (!info) return; - try { - const picked = await open({ - directory: true, - multiple: false, - title: t("settings.dataRoot.chooseTitle"), - }); - if (typeof picked === "string") { - setError(null); - setDestination(resolveDestination(picked, info.app_directory_name)); - } - } catch (err) { - console.error("Failed to choose a folder:", err); - setError(translateBackendError(t, err)); - } - }, [info, t]); - - const handleUseDefault = useCallback(async () => { - try { - setError(null); - setInfo(await invoke("clear_data_root_choice")); - showSuccessToast(t("settings.dataRoot.usingDefault")); - } catch (err) { - console.error("Failed to clear the data directory choice:", err); - setError(translateBackendError(t, err)); - } - }, [t]); - - const handleMove = useCallback(async () => { - if (!destination || movePending.current) return; - movePending.current = true; - setIsMoving(true); - setError(null); - try { - const moved = await invoke("move_data_root", { - destination, - }); - setInfo(moved); - setDestination(null); - showSuccessToast(t("settings.dataRoot.moved")); - } catch (err) { - console.error("Failed to move the data directory:", err); - setError(translateBackendError(t, err)); - setProgress(null); - } finally { - movePending.current = false; - setIsMoving(false); - } - }, [destination, t]); - - if (!info) { - return ( -

- {t("settings.dataRoot.reading")} -

- ); - } - - const phaseLabel = progress - ? t(`settings.dataRoot.phase.${progress.phase}`) - : null; - - return ( -
-
- - {t("settings.dataRoot.current")} - - - {info.active_path} - - - {t("settings.dataRoot.usage", { - size: formatBytes(info.size_bytes), - files: info.file_count, - })} - -
- -

- {t("settings.dataRoot.description")} -

- - {info.active_path_missing && ( -
-

{t("settings.dataRoot.missing")}

- -
- )} - - {info.overridden_by_environment && ( -

- {t("settings.dataRoot.environmentOverride")} -

- )} - -
- - void handleMove()} - > - {t("settings.dataRoot.move")} - -
- - {destination && ( -

- {t("settings.dataRoot.willMoveTo")}{" "} - - {destination} - -

- )} - - {progress && isMoving && ( -
- - - {phaseLabel} - {progress.phase === "copying" && - ` ${t("settings.dataRoot.copiedCount", { - copied: progress.copied_files, - total: progress.total_files, - })}`} - -
- )} - - {info.restart_required && ( -

- {t("settings.dataRoot.restartRequired")} -

- )} - - {error && ( -

- {error} -

- )} -
- ); -} diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 91243261..72c9a220 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -8,7 +8,6 @@ import { motion, useReducedMotion } from "motion/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BsCamera, BsMic } from "react-icons/bs"; -import { DataRootSetting } from "@/components/data-root-setting"; import { DnsBlocklistDialog } from "@/components/dns-blocklist-dialog"; import { LoadingButton } from "@/components/loading-button"; import { useTheme } from "@/components/theme-provider"; @@ -83,17 +82,6 @@ interface AppSettings { keep_decrypted_profiles_in_ram?: boolean; fingerprint_gate_disabled?: boolean; vpn_extension_warning_disabled?: boolean; - trash_retention_days?: number; -} - -/** Retention choices offered for the trash, in days. The backend clamps to 1..=365. */ -const TRASH_RETENTION_OPTIONS = [7, 14, 30, 90, 365]; -const DEFAULT_TRASH_RETENTION_DAYS = 30; - -/** The offered choices, plus the stored value when it is not one of them. */ -function trashRetentionOptions(current: number): number[] { - if (TRASH_RETENTION_OPTIONS.includes(current)) return TRASH_RETENTION_OPTIONS; - return [...TRASH_RETENTION_OPTIONS, current].sort((a, b) => a - b); } interface CustomThemeState { @@ -844,10 +832,6 @@ export function SettingsDialog({ ([key]) => Boolean(settings[key]) !== Boolean(originalSettings[key]), ) .map(([, key]) => t(key)), - ...((settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !== - (originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) - ? [t("settings.trashRetention")] - : []), ]; const hasChanges = pendingChanges.length > 0; const sections = [ @@ -875,7 +859,6 @@ export function SettingsDialog({ ["dns", "dnsBlocklist.title", ["dnsBlocklist"]], ["encryption", "settings.encryption.title", ["settings.encryption"]], ["commercial", "settings.commercial.title", ["settings.commercial"]], - ["dataRoot", "settings.dataRoot.title", ["settings.dataRoot"]], [ "advanced", "settings.advanced.title", @@ -885,8 +868,6 @@ export function SettingsDialog({ "settings.disableAutoUpdatesDescription", "settings.keepDecryptedProfilesInRam", "settings.keepDecryptedProfilesInRamDescription", - "settings.trashRetention", - "settings.trashRetentionDescription", "settings.privacy", ], ], @@ -1656,19 +1637,6 @@ export function SettingsDialog({ - {/* Data directory Section */} - - {/* Advanced Section */}
-
- - -

- {t("settings.trashRetentionDescription")} -

-
- { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 108ff408..4312a132 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2282,6 +2282,7 @@ "wayfernPlanCheckUnavailable": "Could not check your plan right now. Try again in a moment.", "wayfernCustomFingerprintRequiresPlan": "Your plan does not include custom fingerprints.", "wayfernGenerationUnavailable": "Could not create a fingerprint right now. Try again later.", + "profileGenerationLimitReached": "You have created 100 profiles in the last hour, which is the limit on Pro. Wait about {{minutes}} minutes before creating another one.", "wayfernBrowserBusy": "The browser was busy applying a fingerprint. Try again.", "wayfernBrowserExited": "The browser closed while starting. {{detail}}", "profileExportFailed": "This profile could not be exported. {{detail}}", @@ -2580,6 +2581,10 @@ "title": "Browser automation paused", "description": "Browser automation runs on one device at a time, and another signed-in device is currently using it. Sign out there, then relaunch the profile." }, + "profileGenerationLimit": { + "title": "Hourly profile limit reached", + "description": "Your Pro plan allows 100 new profiles per hour. Creation is paused until the hour rolls over, so your account is not blocked." + }, "wayfernConfigDialog": { "titleView": "View Fingerprint Settings - {{name}} ({{browser}})", "titleConfigure": "Configure Fingerprint Settings - {{name}} ({{browser}})", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index bd8af336..40aa0d82 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2292,6 +2292,7 @@ "wayfernPlanCheckUnavailable": "No se pudo comprobar tu plan ahora mismo. Inténtalo de nuevo en un momento.", "wayfernCustomFingerprintRequiresPlan": "Tu plan no incluye huellas digitales personalizadas.", "wayfernGenerationUnavailable": "No se pudo crear una huella digital ahora mismo. Inténtalo de nuevo más tarde.", + "profileGenerationLimitReached": "Has creado 100 perfiles en la última hora, el límite del plan Pro. Espera unos {{minutes}} minutos antes de crear otro.", "wayfernBrowserBusy": "El navegador estaba ocupado aplicando una huella digital. Inténtalo de nuevo.", "wayfernBrowserExited": "El navegador se cerró mientras se iniciaba. {{detail}}", "profileExportFailed": "No se pudo exportar este perfil. {{detail}}", @@ -2590,6 +2591,10 @@ "title": "Automatización del navegador en pausa", "description": "La automatización del navegador funciona en un solo dispositivo a la vez y otro dispositivo con la sesión abierta la está usando. Cierra la sesión allí y vuelve a iniciar el perfil." }, + "profileGenerationLimit": { + "title": "Límite de perfiles por hora alcanzado", + "description": "Tu plan Pro permite 100 perfiles nuevos por hora. La creación está en pausa hasta que avance la hora, para que tu cuenta no se bloquee." + }, "wayfernConfigDialog": { "titleView": "Ver configuración de huella - {{name}} ({{browser}})", "titleConfigure": "Configurar huella - {{name}} ({{browser}})", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e44099d0..3aee53c6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2292,6 +2292,7 @@ "wayfernPlanCheckUnavailable": "Impossible de vérifier votre forfait pour le moment. Réessayez dans un instant.", "wayfernCustomFingerprintRequiresPlan": "Votre forfait n'inclut pas les empreintes personnalisées.", "wayfernGenerationUnavailable": "Impossible de créer une empreinte pour le moment. Réessayez plus tard.", + "profileGenerationLimitReached": "Vous avez créé 100 profils au cours de la dernière heure, la limite de l'offre Pro. Attendez environ {{minutes}} minutes avant d'en créer un autre.", "wayfernBrowserBusy": "Le navigateur était occupé à appliquer une empreinte. Réessayez.", "wayfernBrowserExited": "Le navigateur s'est fermé pendant le démarrage. {{detail}}", "profileExportFailed": "Ce profil n'a pas pu être exporté. {{detail}}", @@ -2590,6 +2591,10 @@ "title": "Automatisation du navigateur en pause", "description": "L'automatisation du navigateur fonctionne sur un seul appareil à la fois, et un autre appareil connecté l'utilise actuellement. Déconnectez-vous sur cet appareil, puis relancez le profil." }, + "profileGenerationLimit": { + "title": "Limite horaire de profils atteinte", + "description": "Votre offre Pro permet 100 nouveaux profils par heure. La création est suspendue jusqu'au renouvellement de la fenêtre, afin que votre compte ne soit pas bloqué." + }, "wayfernConfigDialog": { "titleView": "Voir les paramètres d'empreinte - {{name}} ({{browser}})", "titleConfigure": "Configurer les paramètres d'empreinte - {{name}} ({{browser}})", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cf5069c2..4855c19e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2282,6 +2282,7 @@ "wayfernPlanCheckUnavailable": "現在、ご利用のプランを確認できません。しばらくしてからもう一度お試しください。", "wayfernCustomFingerprintRequiresPlan": "ご利用のプランにはカスタムフィンガープリントが含まれていません。", "wayfernGenerationUnavailable": "現在、フィンガープリントを作成できません。しばらくしてから再度お試しください。", + "profileGenerationLimitReached": "過去1時間に100件のプロファイルを作成しました。これはProプランの上限です。次の作成まで約{{minutes}}分お待ちください。", "wayfernBrowserBusy": "ブラウザーがフィンガープリントを適用中でした。もう一度お試しください。", "wayfernBrowserExited": "ブラウザーが起動中に終了しました。{{detail}}", "profileExportFailed": "このプロファイルを書き出せませんでした。{{detail}}", @@ -2580,6 +2581,10 @@ "title": "ブラウザの自動化が一時停止しました", "description": "ブラウザの自動化は同時に1台のデバイスでのみ利用できます。現在、サインイン中の別のデバイスが使用しています。そのデバイスからサインアウトして、プロファイルを再起動してください。" }, + "profileGenerationLimit": { + "title": "1時間あたりのプロファイル上限に達しました", + "description": "Proプランでは1時間に100件の新規プロファイルを作成できます。アカウントがブロックされないよう、次の時間枠まで作成を一時停止します。" + }, "wayfernConfigDialog": { "titleView": "フィンガープリント設定を表示 - {{name}} ({{browser}})", "titleConfigure": "フィンガープリント設定 - {{name}} ({{browser}})", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index af278e7d..e45ae70f 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2282,6 +2282,7 @@ "wayfernPlanCheckUnavailable": "지금은 요금제를 확인할 수 없습니다. 잠시 후 다시 시도하세요.", "wayfernCustomFingerprintRequiresPlan": "현재 요금제에는 사용자 지정 지문이 포함되어 있지 않습니다.", "wayfernGenerationUnavailable": "지금은 지문을 만들 수 없습니다. 나중에 다시 시도하세요.", + "profileGenerationLimitReached": "지난 1시간 동안 프로필 100개를 만들었습니다. Pro 요금제의 한도입니다. 약 {{minutes}}분 후에 다시 시도하세요.", "wayfernBrowserBusy": "브라우저가 지문을 적용하는 중이었습니다. 다시 시도하세요.", "wayfernBrowserExited": "브라우저가 시작되는 도중에 종료되었습니다. {{detail}}", "profileExportFailed": "이 프로필을 내보낼 수 없습니다. {{detail}}", @@ -2580,6 +2581,10 @@ "title": "브라우저 자동화가 일시 중지됨", "description": "브라우저 자동화는 한 번에 기기 한 대에서만 실행되며, 현재 로그인된 다른 기기가 사용 중입니다. 해당 기기에서 로그아웃한 후 프로필을 다시 실행하세요." }, + "profileGenerationLimit": { + "title": "시간당 프로필 한도에 도달했습니다", + "description": "Pro 요금제는 시간당 100개의 새 프로필을 허용합니다. 계정이 차단되지 않도록 다음 시간대까지 생성을 일시 중지합니다." + }, "wayfernConfigDialog": { "titleView": "핑거프린트 설정 보기 - {{name}} ({{browser}})", "titleConfigure": "핑거프린트 설정 구성 - {{name}} ({{browser}})", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 53dce5e5..a834655e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2292,6 +2292,7 @@ "wayfernPlanCheckUnavailable": "Não foi possível verificar o seu plano agora. Tente novamente em instantes.", "wayfernCustomFingerprintRequiresPlan": "O seu plano não inclui impressões digitais personalizadas.", "wayfernGenerationUnavailable": "Não foi possível criar uma impressão digital agora. Tente novamente mais tarde.", + "profileGenerationLimitReached": "Você criou 100 perfis na última hora, o limite do plano Pro. Aguarde cerca de {{minutes}} minutos antes de criar outro.", "wayfernBrowserBusy": "O navegador estava ocupado com a aplicação de uma impressão digital. Tente novamente.", "wayfernBrowserExited": "O navegador fechou durante a inicialização. {{detail}}", "profileExportFailed": "Não foi possível exportar este perfil. {{detail}}", @@ -2590,6 +2591,10 @@ "title": "Automação do navegador pausada", "description": "A automação do navegador funciona em um dispositivo por vez e outro dispositivo com a sessão ativa está usando-a. Saia da conta nesse dispositivo e inicie o perfil novamente." }, + "profileGenerationLimit": { + "title": "Limite de perfis por hora atingido", + "description": "O seu plano Pro permite 100 novos perfis por hora. A criação fica pausada até a janela reiniciar, para que a sua conta não seja bloqueada." + }, "wayfernConfigDialog": { "titleView": "Ver configurações de impressão digital - {{name}} ({{browser}})", "titleConfigure": "Configurar impressão digital - {{name}} ({{browser}})", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 46fbcc75..0ee8bc5f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2302,6 +2302,7 @@ "wayfernPlanCheckUnavailable": "Сейчас не удалось проверить ваш тариф. Попробуйте снова через минуту.", "wayfernCustomFingerprintRequiresPlan": "Ваш тариф не включает пользовательские отпечатки.", "wayfernGenerationUnavailable": "Сейчас не удалось создать отпечаток. Повторите попытку позже.", + "profileGenerationLimitReached": "За последний час создано 100 профилей — это лимит тарифа Pro. Подождите около {{minutes}} мин. перед созданием следующего.", "wayfernBrowserBusy": "Браузер был занят применением отпечатка. Попробуйте снова.", "wayfernBrowserExited": "Браузер закрылся во время запуска. {{detail}}", "profileExportFailed": "Не удалось экспортировать этот профиль. {{detail}}", @@ -2600,6 +2601,10 @@ "title": "Автоматизация браузера приостановлена", "description": "Автоматизация браузера работает только на одном устройстве одновременно, и сейчас её использует другое устройство с активным входом. Выйдите из аккаунта на нём и запустите профиль снова." }, + "profileGenerationLimit": { + "title": "Достигнут часовой лимит профилей", + "description": "Тариф Pro позволяет создавать 100 новых профилей в час. Создание приостановлено до начала нового часа, чтобы аккаунт не заблокировали." + }, "wayfernConfigDialog": { "titleView": "Просмотр настроек отпечатка - {{name}} ({{browser}})", "titleConfigure": "Настроить отпечаток - {{name}} ({{browser}})", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index a2c5cbf2..9604b22a 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -2282,6 +2282,7 @@ "wayfernPlanCheckUnavailable": "Planınız şu anda kontrol edilemedi. Kısa süre sonra yeniden deneyin.", "wayfernCustomFingerprintRequiresPlan": "Planınız özel parmak izlerini içermiyor.", "wayfernGenerationUnavailable": "Şu anda parmak izi oluşturulamadı. Daha sonra tekrar deneyin.", + "profileGenerationLimitReached": "Son bir saatte 100 profil oluşturdunuz; bu, Pro planının sınırıdır. Yenisini oluşturmadan önce yaklaşık {{minutes}} dakika bekleyin.", "wayfernBrowserBusy": "Tarayıcı bir parmak izi uygulamakla meşguldü. Tekrar deneyin.", "wayfernBrowserExited": "Tarayıcı başlatılırken kapandı. {{detail}}", "profileExportFailed": "Bu profil dışa aktarılamadı. {{detail}}", @@ -2580,6 +2581,10 @@ "title": "Tarayıcı otomasyonu duraklatıldı", "description": "Tarayıcı otomasyonu aynı anda yalnızca tek bir cihazda çalışır ve şu anda oturum açmış başka bir cihaz tarafından kullanılıyor. O cihazdan çıkış yapın, ardından profili yeniden başlatın." }, + "profileGenerationLimit": { + "title": "Saatlik profil sınırına ulaşıldı", + "description": "Pro planınız saatte 100 yeni profile izin verir. Hesabınız engellenmesin diye oluşturma, yeni saat dilimine kadar duraklatıldı." + }, "wayfernConfigDialog": { "titleView": "Parmak İzi Ayarlarını Görüntüle - {{name}} ({{browser}})", "titleConfigure": "Parmak İzi Ayarlarını Yapılandır - {{name}} ({{browser}})", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index e0e2410e..7b2e2bf5 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -2282,6 +2282,7 @@ "wayfernPlanCheckUnavailable": "Hiện không thể kiểm tra gói của bạn. Hãy thử lại sau giây lát.", "wayfernCustomFingerprintRequiresPlan": "Gói của bạn không bao gồm dấu vân tay tùy chỉnh.", "wayfernGenerationUnavailable": "Hiện không thể tạo dấu vân tay. Vui lòng thử lại sau.", + "profileGenerationLimitReached": "Bạn đã tạo 100 hồ sơ trong giờ qua, đây là giới hạn của gói Pro. Hãy đợi khoảng {{minutes}} phút trước khi tạo hồ sơ tiếp theo.", "wayfernBrowserBusy": "Trình duyệt đang bận áp dụng dấu vân tay. Hãy thử lại.", "wayfernBrowserExited": "Trình duyệt đã đóng khi đang khởi động. {{detail}}", "profileExportFailed": "Không thể xuất hồ sơ này. {{detail}}", @@ -2580,6 +2581,10 @@ "title": "Tự động hóa trình duyệt đã tạm dừng", "description": "Tự động hóa trình duyệt chỉ chạy trên một thiết bị tại một thời điểm và một thiết bị khác đang đăng nhập hiện đang sử dụng nó. Hãy đăng xuất trên thiết bị đó, rồi khởi chạy lại cấu hình." }, + "profileGenerationLimit": { + "title": "Đã đạt giới hạn hồ sơ mỗi giờ", + "description": "Gói Pro cho phép tạo 100 hồ sơ mới mỗi giờ. Việc tạo hồ sơ tạm dừng cho đến khi sang giờ mới để tài khoản của bạn không bị khóa." + }, "wayfernConfigDialog": { "titleView": "Xem cài đặt vân tay - {{name}} ({{browser}})", "titleConfigure": "Cấu hình cài đặt vân tay - {{name}} ({{browser}})", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 45c96ae5..7f8fc1e8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2282,6 +2282,7 @@ "wayfernPlanCheckUnavailable": "目前无法检查你的套餐。请稍后再试。", "wayfernCustomFingerprintRequiresPlan": "你的套餐不包含自定义指纹。", "wayfernGenerationUnavailable": "目前无法创建指纹。请稍后再试。", + "profileGenerationLimitReached": "过去一小时内你已创建 100 个配置文件,已达 Pro 套餐上限。请等待约 {{minutes}} 分钟后再创建。", "wayfernBrowserBusy": "浏览器正忙于应用指纹。请重试。", "wayfernBrowserExited": "浏览器在启动过程中关闭了。{{detail}}", "profileExportFailed": "无法导出此配置文件。{{detail}}", @@ -2580,6 +2581,10 @@ "title": "浏览器自动化已暂停", "description": "浏览器自动化一次只能在一台设备上运行,目前有另一台已登录的设备正在使用。请在该设备上退出登录,然后重新启动此配置文件。" }, + "profileGenerationLimit": { + "title": "已达到每小时的配置文件上限", + "description": "Pro 套餐每小时可新建 100 个配置文件。为避免账号被封禁,新建操作将暂停到下一个小时。" + }, "wayfernConfigDialog": { "titleView": "查看指纹设置 - {{name}} ({{browser}})", "titleConfigure": "配置指纹设置 - {{name}} ({{browser}})", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index d88405d1..94ec20fe 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -217,6 +217,9 @@ export type BackendErrorCode = | "WAYFERN_PLAN_CHECK_UNAVAILABLE" | "WAYFERN_CUSTOM_FINGERPRINT_REQUIRES_PLAN" | "WAYFERN_GENERATION_UNAVAILABLE" + // Donut's own hourly cap on Pro accounts, refused locally before the request + // reaches Wayfern. `retryAfterSeconds` says when the window reopens. + | "PROFILE_GENERATION_LIMIT_REACHED" | "WAYFERN_BROWSER_BUSY" | "WAYFERN_BROWSER_EXITED" | "PROFILE_EXPORT_FAILED" @@ -235,23 +238,6 @@ export type BackendErrorCode = | "NO_E2E_PASSWORD_SET" | "TRASH_ENTRY_NOT_FOUND" | "TRASH_RESTORE_CONFLICT" - // Moving the data directory. Every refusal is its own code because every one - // of them has a different fix: close the browsers, wait for the sync, pick - // somewhere else, free some space. - | "DATA_ROOT_SAME_AS_CURRENT" - | "DATA_ROOT_DESTINATION_INSIDE_SOURCE" - | "DATA_ROOT_BROWSER_RUNNING" - | "DATA_ROOT_SYNC_IN_PROGRESS" - // `params.required` / `params.available`, both in bytes as strings. - | "DATA_ROOT_INSUFFICIENT_SPACE" - | "DATA_ROOT_DESTINATION_NOT_WRITABLE" - | "DATA_ROOT_DESTINATION_NOT_EMPTY" - | "DATA_ROOT_MOVE_IN_PROGRESS" - | "DATA_ROOT_COPY_FAILED" - // The copy did not match the source, so nothing was deleted. Its own code - // rather than a copy failure: the user's data is still where it was, and - // saying so is the whole point of verifying before deleting. - | "DATA_ROOT_VERIFY_FAILED" // Synchronised windows. | "SYNC_SESSION_NOT_FOUND" | "SYNC_FOLLOWER_NOT_FOUND" @@ -771,6 +757,17 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.wayfernCustomFingerprintRequiresPlan"); case "WAYFERN_GENERATION_UNAVAILABLE": return t("backendErrors.wayfernGenerationUnavailable"); + case "PROFILE_GENERATION_LIMIT_REACHED": { + const seconds = Number.parseInt( + parsed.params?.retryAfterSeconds ?? "", + 10, + ); + return t("backendErrors.profileGenerationLimitReached", { + minutes: Number.isFinite(seconds) + ? Math.max(1, Math.ceil(seconds / 60)) + : 60, + }); + } case "WAYFERN_BROWSER_BUSY": return t("backendErrors.wayfernBrowserBusy"); case "WAYFERN_BROWSER_EXITED": @@ -817,34 +814,6 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.trashEntryNotFound"); case "TRASH_RESTORE_CONFLICT": return t("backendErrors.trashRestoreConflict"); - case "DATA_ROOT_SAME_AS_CURRENT": - return t("backendErrors.dataRootSameAsCurrent"); - case "DATA_ROOT_DESTINATION_INSIDE_SOURCE": - return t("backendErrors.dataRootDestinationInsideSource"); - case "DATA_ROOT_BROWSER_RUNNING": - return t("backendErrors.dataRootBrowserRunning"); - case "DATA_ROOT_SYNC_IN_PROGRESS": - return t("backendErrors.dataRootSyncInProgress"); - // The figures arrive already written out. This module is imported by a - // plain `node --test` run, so it stays free of project imports, and the - // one place that knows the byte counts formats them. - case "DATA_ROOT_INSUFFICIENT_SPACE": - return t("backendErrors.dataRootInsufficientSpace", { - required: parsed.params?.required ?? "", - available: parsed.params?.available ?? "", - }); - case "DATA_ROOT_DESTINATION_NOT_WRITABLE": - return t("backendErrors.dataRootDestinationNotWritable"); - case "DATA_ROOT_DESTINATION_NOT_EMPTY": - return t("backendErrors.dataRootDestinationNotEmpty"); - case "DATA_ROOT_MOVE_IN_PROGRESS": - return t("backendErrors.dataRootMoveInProgress"); - case "DATA_ROOT_COPY_FAILED": - return t("backendErrors.dataRootCopyFailed", { - detail: parsed.params?.detail ?? "", - }); - case "DATA_ROOT_VERIFY_FAILED": - return t("backendErrors.dataRootVerifyFailed"); case "SYNC_SESSION_NOT_FOUND": return t("backendErrors.syncSessionNotFound"); case "SYNC_FOLLOWER_NOT_FOUND": diff --git a/src/types.ts b/src/types.ts index 5d6f2ad3..e85bb749 100644 --- a/src/types.ts +++ b/src/types.ts @@ -676,28 +676,6 @@ export interface SyncSessionInfo { /** How the follower windows are placed on the host display. */ export type SyncWindowLayout = "grid" | "columns" | "cascade"; -// Data directory -export interface DataRootInfo { - active_path: string; - configured_path: string | null; - default_path: string; - size_bytes: number; - file_count: number; - overridden_by_environment: boolean; - restart_required: boolean; - app_directory_name: string; - active_path_missing: boolean; -} - -export interface DataRootMoveProgress { - phase: "scanning" | "copying" | "verifying" | "cleaning" | "done"; - copied_files: number; - total_files: number; - copied_bytes: number; - total_bytes: number; - destination: string; -} - // Traffic stats types export interface BandwidthDataPoint { timestamp: number;