From f12a84e18fa3380d32f750746ab094fe1fc96a7d Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:39:53 -0700 Subject: [PATCH] refactor: cleanup --- .gitignore | 7 + e2e/app/Cargo.lock | 1 + e2e/coverage-map.mjs | 6 + e2e/tests/entities.test.mjs | 28 + e2e/tests/integrations.test.mjs | 27 + e2e/tests/smoke.test.mjs | 30 +- e2e/tests/ui.test.mjs | 18 +- package.json | 3 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 4 + src-tauri/capabilities/default.json | 1 + src-tauri/src/api_server.rs | 31 +- src-tauri/src/cloud_auth.rs | 15 + src-tauri/src/cookie_bot.rs | 653 +++++++++- src-tauri/src/launch_gate.rs | 86 +- src-tauri/src/lib.rs | 94 +- src-tauri/src/mcp_server.rs | 39 +- src-tauri/src/proxy_manager.rs | 6 +- src-tauri/src/remote_exit.rs | 511 ++++++++ src-tauri/src/remote_session.rs | 101 ++ .../src/vpn_extension_detect/browser_scan.rs | 63 +- src-tauri/src/vpn_extension_detect/mod.rs | 13 +- src-tauri/src/window_decorations.rs | 182 +++ src-tauri/src/xray/error.rs | 39 + src-tauri/src/xray/uri.rs | 141 ++- src-tauri/src/xray_worker_runner.rs | 6 +- src-tauri/src/xray_worker_storage.rs | 35 + src/app/page.tsx | 7 +- src/components/cookie-bot-enrol-dialog.tsx | 1078 +++++++++++++++-- src/components/cookie-bot-shared.tsx | 110 ++ src/components/home-header.tsx | 26 +- src/components/proxy-form-dialog.tsx | 44 +- src/components/window-drag-area.tsx | 283 +++-- src/components/window-resize-handles.tsx | 137 +++ src/hooks/use-window-decorations.ts | 76 ++ src/i18n/locales/en.json | 61 +- src/i18n/locales/es.json | 63 +- src/i18n/locales/fr.json | 63 +- src/i18n/locales/ja.json | 61 +- src/i18n/locales/ko.json | 61 +- src/i18n/locales/pt.json | 63 +- src/i18n/locales/ru.json | 65 +- src/i18n/locales/tr.json | 61 +- src/i18n/locales/vi.json | 61 +- src/i18n/locales/zh.json | 61 +- src/lib/backend-errors.ts | 43 +- src/lib/cookie-bot.ts | 165 ++- src/lib/window-decorations.test.mjs | 116 ++ src/lib/window-decorations.ts | 86 ++ 49 files changed, 4555 insertions(+), 377 deletions(-) create mode 100644 src-tauri/src/remote_exit.rs create mode 100644 src-tauri/src/window_decorations.rs create mode 100644 src/components/window-resize-handles.tsx create mode 100644 src/hooks/use-window-decorations.ts create mode 100644 src/lib/window-decorations.test.mjs create mode 100644 src/lib/window-decorations.ts diff --git a/.gitignore b/.gitignore index 1e994fc..e65995d 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,10 @@ nodecar/nodecar-bin # claude .claude/ +# Claude Code session-recovery runtime state +HANDOFF.md +.claude/settings.local.json +.claude/rate-limit-state.json +.claude/stop-failure-events.jsonl +.claude/quota-blocked.json +session-recover.yaml diff --git a/e2e/app/Cargo.lock b/e2e/app/Cargo.lock index 85eab14..a4197c3 100644 --- a/e2e/app/Cargo.lock +++ b/e2e/app/Cargo.lock @@ -1809,6 +1809,7 @@ dependencies = [ "flate2", "futures-util", "globset", + "gtk", "http-body-util", "hyper", "hyper-util", diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 223e126..87724ba 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -25,6 +25,7 @@ export const commandCoverage = { "get_system_info", "dismiss_window_resize_warning", "get_window_resize_warning_dismissed", + "window_decorations::get_window_decoration_layout", "get_onboarding_completed", "complete_onboarding", ], @@ -71,6 +72,7 @@ export const commandCoverage = { "update_stored_proxy", "delete_stored_proxy", "check_proxy_validity", + "validate_vless_uri", "get_cached_proxy_check", "export_proxies", "import_proxies_json", @@ -277,6 +279,10 @@ export const commandCoverage = { "get_cookie_bot_presets", "get_remote_hours_quota", "get_cookie_bot_usage", + "cookie_bot::get_cookie_bot_user_templates", + "cookie_bot::create_cookie_bot_user_template", + "cookie_bot::update_cookie_bot_user_template", + "cookie_bot::delete_cookie_bot_user_template", ], }, updateContracts: { diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index 3613bb2..77ac13d 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -93,6 +93,34 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a }); assert.ok(cachedValidity === null || cachedValidity.is_valid === false); + // Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form + // uses this to tell the user WHICH part of their setup is unsupported + // instead of implying they mistyped, so the reason must survive the IPC hop. + const goodVless = + "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@example.com:443" + + "?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp" + + "&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome"; + assert.equal( + await app.invoke("validate_vless_uri", { uri: goodVless }), + null, + ); + + for (const [uri, reason] of [ + [goodVless.replace("security=reality", "security=tls"), "security"], + [goodVless.replace("type=tcp", "type=ws"), "transport"], + [goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"], + ]) { + // invokeError returns the command's error wrapped in a message, so match + // rather than JSON.parse the whole string. + const error = await app.invokeError("validate_vless_uri", { uri }); + assert.match(error, /VLESS_CONFIG_INVALID/); + assert.match( + error, + new RegExp(`"reason":"${reason}"`), + `expected reason ${reason} for ${uri}, got: ${error}`, + ); + } + const exported = JSON.parse( await app.invoke("export_proxies", { format: "json" }), ); diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index 179573e..85a5bd3 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -718,6 +718,33 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de }), notSignedIn, ); + // Saved site lists are cloud-backed like the schedules above, so they + // must refuse the same way rather than appearing to work offline. + assert.match( + await app.invokeError("get_cookie_bot_user_templates", {}), + notSignedIn, + ); + assert.match( + await app.invokeError("create_cookie_bot_user_template", { + name: "e2e list", + sites: ["example.com"], + }), + notSignedIn, + ); + assert.match( + await app.invokeError("update_cookie_bot_user_template", { + id: "00000000-0000-0000-0000-000000000000", + name: "renamed", + sites: null, + }), + notSignedIn, + ); + assert.match( + await app.invokeError("delete_cookie_bot_user_template", { + id: "00000000-0000-0000-0000-000000000000", + }), + notSignedIn, + ); assert.match( await app.invokeError("check_cookie_bot_conflicts", { profileId: missingProfileId, diff --git a/e2e/tests/smoke.test.mjs b/e2e/tests/smoke.test.mjs index f77c8c0..32f9480 100644 --- a/e2e/tests/smoke.test.mjs +++ b/e2e/tests/smoke.test.mjs @@ -22,6 +22,30 @@ test("fresh app renders, completes onboarding, persists settings, and never touc true, ); + // Where the app draws its own titlebar it also owns the window controls, + // so it needs the desktop's button layout to know which side they go on. + const decorations = await app.invoke("get_window_decoration_layout"); + assert.equal(typeof decorations?.client_side, "boolean"); + if (decorations.client_side) { + // Only reported where decorations were actually dropped, which is + // every Linux session except KDE on Wayland. + assert.equal(process.platform, "linux"); + // `layout` may be null when GtkSettings is unavailable; the frontend + // falls back to the default arrangement rather than drawing nothing, + // so asserting a string here would be stricter than the contract. + if (decorations.layout !== null) { + assert.equal(typeof decorations.layout, "string"); + assert.match( + decorations.layout, + /close|minimize|maximize/, + `layout must name a drawable control, got: ${decorations.layout}`, + ); + } + } else { + // The platform still draws a titlebar; the app must not draw a second. + assert.equal(decorations.layout, null); + } + const saved = await app.invoke("save_app_settings", { settings: { ...initial, @@ -113,8 +137,12 @@ test("keyboard command palette and major navigation surfaces are operable throug assert.match(body, /Settings/i); // Exercise native WebDriver element marshalling and click, not just script execution. + // Scoped to the open dialog on purpose: on Linux the app draws its own + // titlebar, whose "Close window" control appears earlier in the DOM, and + // clicking that would exercise the window lifecycle instead of the palette. const close = await app.execute( - `return [...document.querySelectorAll("button")].find( + `const dialog = document.querySelector("[role='dialog']") ?? document; + return [...dialog.querySelectorAll("button")].find( (button) => /close/i.test(button.getAttribute("aria-label") || button.textContent || "") ) ?? null;`, ); diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 5bee11a..905eb35 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -474,7 +474,7 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async await app.clickSelector('[aria-label="New proxy"]'); await app.waitForText("Add Proxy"); await app.fillSelector("#proxy-name", "E2E VLESS"); - await chooseSelectOption(app, "#proxy-type", "VLESS · Vision · REALITY"); + await chooseSelectOption(app, "#proxy-type", "VLESS"); assert.equal( await app.execute( @@ -512,6 +512,22 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async true, ); + // A well-formed URI for a setup Donut cannot use must say WHICH part is + // unsupported, rather than implying the user mistyped it. + await app.fillSelector( + "#proxy-vless-uri", + `${uri.replace("type=tcp", "type=ws")}&path=%2Fray`, + ); + await app.waitFor( + () => + app.execute( + `return /only|TCP|transport/i.test( + document.querySelector("#proxy-vless-uri-help")?.textContent || "" + );`, + ), + { description: "transport-specific unsupported message" }, + ); + await app.fillSelector("#proxy-vless-uri", uri); await app.waitFor( () => diff --git a/package.json b/package.json index 686c3ac..cc74b15 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "prebuild": "pnpm licenses:generate", "build": "next build", "start": "next start", - "test": "pnpm test:themes && pnpm test:cookie-bot-limits && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", + "test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", "test:themes": "node --test src/lib/themes.test.mjs", + "test:window-decorations": "node --test src/lib/window-decorations.test.mjs", "test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs", "test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check", "test:xray-packaging": "node --test src-tauri/download-xray.test.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index db1bde9..77284dc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1821,6 +1821,7 @@ dependencies = [ "flate2", "futures-util", "globset", + "gtk", "http-body-util", "hyper", "hyper-util", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a79cad5..df460de 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -116,6 +116,10 @@ sys-locale = "0.3" [target.'cfg(unix)'.dependencies] nix = { version = "0.31", features = ["signal", "process"] } +# Reading the desktop's titlebar button layout for the in-app window controls. +[target.'cfg(target_os = "linux")'.dependencies] +gtk = "0.18" + [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10" objc2 = "0.6.4" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 08515a8..ced9ed4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:event:allow-emit-to", "core:event:allow-unlisten", "core:window:allow-start-dragging", + "core:window:allow-start-resize-dragging", "core:window:allow-close", "core:window:allow-is-maximized", "core:window:allow-minimize", diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 463e596..0f88d89 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -3033,7 +3033,7 @@ fn cookie_bot_eligible_profile( .find(|p| p.id.to_string() == profile_id) .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; - crate::cookie_bot::bot_precondition(&profile) + crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile)) .map_err(|reason| (StatusCode::BAD_REQUEST, reason))?; Ok(profile) } @@ -3315,10 +3315,7 @@ async fn list_cookie_bot_runs( async fn start_cookie_bot_run( Json(request): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { - if !crate::cloud_auth::CLOUD_AUTH - .can_use_browser_automation() - .await - { + if !crate::cloud_auth::CLOUD_AUTH.can_use_cookie_bot().await { return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } @@ -4234,14 +4231,22 @@ mod tests { let mut local_only = profile_with(SyncMode::Disabled, Some("macos")); local_only.proxy_id = Some("proxy-1".to_string()); assert!( - crate::cookie_bot::bot_precondition(&local_only).is_err(), + crate::cookie_bot::bot_precondition( + &local_only, + &crate::remote_exit::ExitReachability::Remote + ) + .is_err(), "a profile with no cloud copy has nothing for a host to open" ); let mut encrypted = profile_with(SyncMode::Encrypted, Some("macos")); encrypted.proxy_id = Some("proxy-1".to_string()); assert!( - crate::cookie_bot::bot_precondition(&encrypted).is_err(), + crate::cookie_bot::bot_precondition( + &encrypted, + &crate::remote_exit::ExitReachability::Remote + ) + .is_err(), "a host cannot decrypt a profile whose key never leaves this machine" ); @@ -4249,13 +4254,21 @@ mod tests { datacenter_egress.proxy_id = None; datacenter_egress.vpn_id = None; assert!( - crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(), + crate::cookie_bot::bot_precondition( + &datacenter_egress, + &crate::remote_exit::ExitReachability::None + ) + .is_err(), "hours of traffic from a hosting ASN damages the identity being warmed" ); let mut eligible = profile_with(SyncMode::Regular, Some("macos")); eligible.proxy_id = Some("proxy-1".to_string()); - assert!(crate::cookie_bot::bot_precondition(&eligible).is_ok()); + assert!(crate::cookie_bot::bot_precondition( + &eligible, + &crate::remote_exit::ExitReachability::Remote + ) + .is_ok()); } #[test] diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 9691aaf..afa9229 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -819,6 +819,21 @@ impl CloudAuthManager { } /// Launch/drive profiles programmatically (local API + MCP automation). + /// Whether this account may run the nightly Cookie Bot. + /// + /// NOT `can_use_browser_automation`. Solo is exactly the plan where the two + /// disagree — it pays for a nightly bot and has `browser_automation: false` — + /// so gating the bot on automation refused a Solo customer the one feature + /// their plan is sold on, and answered 402 while their scheduled runs kept + /// working server-side. + pub async fn can_use_cookie_bot(&self) -> bool { + self + .entitlements() + .await + .map(|e| e.cookie_bot) + .unwrap_or(false) + } + pub async fn can_use_browser_automation(&self) -> bool { #[cfg(feature = "e2e")] if crate::e2e_automation_enabled() diff --git a/src-tauri/src/cookie_bot.rs b/src-tauri/src/cookie_bot.rs index b9ed138..a7d846a 100644 --- a/src-tauri/src/cookie_bot.rs +++ b/src-tauri/src/cookie_bot.rs @@ -51,6 +51,20 @@ const REPORT_CODES: FailureCodes = FailureCodes { conflict: cloud_errors::UNAVAILABLE, }; +/// Failure codes for the user-template routes. +/// +/// Distinct from `SCHEDULE_CODES` on every axis that matters: a 404 here is a +/// template that was deleted (possibly from another device), not an unenrolled +/// profile, and a 409 is a name the user already used, not a teammate's +/// enrolment. Sharing the schedule set would have told someone renaming a site +/// list that a colleague already warms this profile. +const TEMPLATE_CODES: FailureCodes = FailureCodes { + bad_request: "COOKIE_BOT_INVALID_TEMPLATE_NAME", + forbidden: "COOKIE_BOT_NOT_ENTITLED", + not_found: "COOKIE_BOT_TEMPLATE_NOT_FOUND", + conflict: "COOKIE_BOT_TEMPLATE_NAME_TAKEN", +}; + /// Every cookie-bot call fails as a code the frontend can translate. /// /// There is no `Other(String)` carrying backend English: a raw message reaches @@ -91,6 +105,18 @@ impl From for CookieBotError { // One place for every request and response shape, so a backend contract change // is a single edit here rather than a hunt through call sites. +/// One time-of-day an enrolment fires, on a set of local weekdays. +/// +/// Copy, and deliberately tiny: a calendar is a list of these, and the desktop +/// rebuilds that list on every keystroke in the enrolment form. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotSlot { + /// Bitmask of local weekdays, bit 0 = Monday. At least one bit set. + pub days_mask: u8, + /// Minutes past local midnight, in the schedule's timezone. + pub run_at_minute: u16, +} + /// A profile enrolled in the nightly bot. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CookieBotSchedule { @@ -98,13 +124,31 @@ pub struct CookieBotSchedule { pub profile_name: String, pub platform: String, pub enabled: bool, - /// Minutes past local midnight the run is anchored to. + /// Minutes past local midnight the FIRST slot is anchored to. The server + /// mirrors `slots[0]` onto this pair on every write. pub run_at_minute: u16, - /// Bitmask of local weekdays, bit 0 = Monday. + /// The first slot's weekdays, bit 0 = Monday. See `run_at_minute`. pub days_mask: u8, + /// Every time-of-day this enrolment fires. + /// + /// `default` rather than required because a server older than multi-slot + /// scheduling sends only the mirrored pair above, and a decode failure there + /// would blank the whole Cookie Bot surface rather than show one time instead + /// of several. Callers must therefore fall back to the pair when this is + /// empty — never treat an empty list as "fires at no time". + #[serde(default)] + pub slots: Vec, pub timezone: String, /// Server-issued preset id. Opaque here — what it expands to is infra's. pub preset: String, + /// The template the sites came from, or `None` for the user's own list. + /// + /// A built-in id (`low-intent-purchaser`) means `sites` is EMPTY on purpose: + /// its URLs are server-owned and never sent to a client. A `user:` id + /// is provenance only — those sites were copied onto the enrolment and are + /// present below. + #[serde(default)] + pub template_id: Option, pub max_minutes: u32, #[serde(default)] pub sites: Vec, @@ -120,6 +164,11 @@ pub struct CookieBotSchedule { pub encrypted_sync: bool, #[serde(default)] pub has_proxy: bool, + /// Whether that exit is one a leased fleet host could dial. Defaults to false + /// on an older server that does not send it, which reads as "not reachable" + /// and is the safe direction. + #[serde(default)] + pub proxy_remote_reachable: bool, #[serde(default)] pub touch_fingerprint: bool, #[serde(default)] @@ -162,8 +211,26 @@ pub struct CookieBotScheduleInput { pub enabled: bool, pub run_at_minute: u16, pub days_mask: u8, + /// The whole calendar, when the caller has one. + /// + /// `skip_serializing_if` is load-bearing rather than tidiness: the server + /// reads an ABSENT `slots` as "one slot, from the pair above" and refuses a + /// present-but-empty one, and `null` takes the refusing branch. Serialising + /// `None` as null would 400 every write from a single-slot form. + /// + /// The pair above is still sent, mirrored from `slots[0]`, so a server that + /// predates multi-slot stores the first time rather than nothing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slots: Option>, pub timezone: String, pub preset: String, + /// A browsing template instead of a typed site list. + /// + /// Mutually exclusive with a non-empty `sites`: the server refuses a write + /// carrying both, because merging a curated persona with the user's own list + /// produces neither. A caller naming a template sends `sites: []`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub template_id: Option, pub max_minutes: u32, pub sites: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -191,6 +258,8 @@ pub struct CookieBotScheduleInput { #[serde(default)] pub has_proxy: bool, #[serde(default)] + pub proxy_remote_reachable: bool, + #[serde(default)] pub encrypted_sync: bool, #[serde(default)] pub touch_fingerprint: bool, @@ -331,6 +400,53 @@ pub struct CookieBotPreset { pub description: Option, } +/// A server-owned browsing template: a named answer to "what is this profile +/// for", which the user picks INSTEAD of typing a site list. +/// +/// Carries no URLs, and must not gain any. The pool a template draws from is +/// server-side for the same reason a preset's browsing model is: a published +/// list is one a retailer can filter, and each profile is given its own sample +/// so the template never becomes a fleet-wide fingerprint. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotTemplate { + pub id: String, + /// How many sites this template browses. Not which. + #[serde(default)] + pub site_count: u32, + /// Server-supplied English label and blurb, present only so a template added + /// after this build still renders. The UI prefers its own `t()` key for an id + /// it recognises. + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, +} + +/// The bounds the schedule routes actually enforce, as this build reads them. +/// +/// Every field is optional because a server that predates `limits` sends none +/// of them, and a client that read a missing bound as `0` would refuse every +/// value the form can produce. Only the bounds the desktop acts on are decoded +/// — serde drops the rest, and this struct is what the GUI ultimately receives, +/// so adding a field here is what makes one reachable from TypeScript. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotLimits { + #[serde(default)] + pub min_minutes: Option, + #[serde(default)] + pub max_minutes: Option, + #[serde(default)] + pub min_sites: Option, + #[serde(default)] + pub max_sites: Option, + /// Most entries a calendar may carry. + #[serde(default)] + pub max_slots: Option, + /// Longest name a saved site list may be given. + #[serde(default)] + pub max_template_name_length: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CookieBotPresetList { #[serde(default)] @@ -339,6 +455,33 @@ pub struct CookieBotPresetList { /// preference. #[serde(default)] pub default_preset: Option, + /// The curated templates on offer. Served beside the presets so a template + /// added server-side appears without a desktop release. + #[serde(default)] + pub templates: Vec, + /// The server's own bounds, when it publishes them. The desktop mirrors a + /// copy for offline form validation; these win where they disagree. + #[serde(default)] + pub limits: Option, +} + +/// One of the caller's OWN saved site lists. +/// +/// Carries its URLs, unlike {@link CookieBotTemplate} — they are the user's own +/// and there is nothing to withhold. Applying one copies the sites onto the +/// enrolment, so a list edited later does not silently change what an existing +/// enrolment browses until it is saved again. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotUserTemplate { + /// Already carries the `user:` prefix: this id's job is to be pasted into a + /// schedule's `template_id`, and assembling that convention on the client is + /// how the two kinds of template get confused. + pub id: String, + pub name: String, + #[serde(default)] + pub sites: Vec, + #[serde(default)] + pub updated_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -465,7 +608,10 @@ pub struct CookieBotUsage { /// the client cannot see — but a profile that can never qualify should never /// reach a confirm dialog, an hour of quota or a leased host. Returns the /// `{"code":…}` string a Tauri command surfaces directly. -pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> { +pub fn bot_precondition( + profile: &BrowserProfile, + exit: &crate::remote_exit::ExitReachability, +) -> Result<(), String> { if !profile.is_sync_enabled() { // The host materialises the profile by pulling it from donut-sync. A // local-only profile has nothing there, so there is no path to a run. @@ -491,6 +637,21 @@ pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> { // than not warming it at all. return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[])); } + // ...and the exit has to be one the leased host can reach. The profile and its + // proxy record are pulled onto the fleet with no address rewriting, so + // 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH + // tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the + // run fails an hour has been leased and billed. + // + // Taken as an ARGUMENT rather than resolved here, for the same reason + // `ProfileState` is required rather than defaulted: resolving it needs the + // proxy and VPN stores, and a function that reaches into those globals is one + // no test can set up and every caller silently depends on. `exit_reachability` + // is the one place that resolution happens; this stays a pure predicate over + // facts it is handed. + if !exit.is_remote() { + return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[])); + } Ok(()) } @@ -511,6 +672,12 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState { // A VPN is an exit node just as much as a proxy is; the server only asks // whether the traffic leaves through something the user brought. has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(), + // ...and, separately, whether anyone OTHER than this machine could use it. + // `has_proxy` answers "did the user bring an exit"; this answers "is that + // exit an address a leased host can dial". They disagree for every local + // proxy, which is the case that used to be accepted and then fail on the + // fleet. See `remote_exit`. + proxy_remote_reachable: exit_reachability(profile).is_remote(), // Always false: this data model has no mobile/touch profile. `resolved_os` // yields only windows, macos or linux, and `bot_precondition` already // refuses everything but the first two. Reported rather than omitted so the @@ -533,16 +700,69 @@ pub struct ProfileState { pub sync_enabled: bool, pub encrypted_sync: bool, pub has_proxy: bool, + /// Whether that exit is an address a leased fleet host can dial. + pub proxy_remote_reachable: bool, pub touch_fingerprint: bool, pub sticky_exit: bool, } +/// Whether this profile's exit could be used from a host that is not this one. +/// +/// Resolves the profile's proxy or VPN out of local storage — the server cannot +/// do this, because it never sees a proxy record until sync has uploaded one and +/// even then would have to re-derive what the browser will actually dial. +/// +/// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is +/// what the browser is pointed at, and it is the address the fleet has to reach. +pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability { + use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability}; + + if let Some(proxy_id) = profile.proxy_id.as_deref() { + let stored = crate::proxy_manager::PROXY_MANAGER + .get_stored_proxies() + .into_iter() + .find(|candidate| candidate.id == proxy_id); + return match stored { + Some(proxy) => classify_proxy(&proxy.proxy_settings), + // Referenced but missing. Fail closed: a dangling id is not evidence of a + // reachable exit, and the launch would fail anyway. + None => ExitReachability::Unknown { + reason: "the profile references a proxy that no longer exists".to_string(), + source: "proxy", + }, + }; + } + + if let Some(vpn_id) = profile.vpn_id.as_deref() { + let config = crate::vpn::VPN_STORAGE + .lock() + .ok() + .and_then(|storage| storage.load_config(vpn_id).ok()); + return match config { + Some(config) => match crate::vpn::parse_wireguard_config(&config.config_data) { + Ok(parsed) => classify_wireguard_endpoint(&parsed.peer_endpoint), + Err(error) => ExitReachability::Unknown { + reason: format!("VPN config could not be parsed ({error})"), + source: "VPN", + }, + }, + None => ExitReachability::Unknown { + reason: "the profile references a VPN config that no longer exists".to_string(), + source: "VPN", + }, + }; + } + + ExitReachability::None +} + impl CookieBotScheduleInput { /// Stamp the profile facts onto an input built from user-chosen values. pub fn with_profile_state(mut self, state: ProfileState) -> Self { self.sync_enabled = state.sync_enabled; self.encrypted_sync = state.encrypted_sync; self.has_proxy = state.has_proxy; + self.proxy_remote_reachable = state.proxy_remote_reachable; self.touch_fingerprint = state.touch_fingerprint; self.sticky_exit = state.sticky_exit; self @@ -663,6 +883,10 @@ pub async fn update_profile_state( body.insert("sync_enabled".to_string(), state.sync_enabled.into()); body.insert("encrypted_sync".to_string(), state.encrypted_sync.into()); body.insert("has_proxy".to_string(), state.has_proxy.into()); + body.insert( + "proxy_remote_reachable".to_string(), + state.proxy_remote_reachable.into(), + ); body.insert( "touch_fingerprint".to_string(), state.touch_fingerprint.into(), @@ -855,6 +1079,171 @@ pub async fn list_presets() -> Result { .await } +// --- User-defined templates ------------------------------------------------- +// +// The caller's own saved site lists. Unlike every other route in this file +// these are addressed by an id the SERVER minted and the client echoes back, +// so each one percent-encodes it: the id is spelled `user:`, and a bare +// colon in a path segment is a spelling the router is free to read differently. + +#[derive(Debug, Deserialize)] +struct UserTemplateListEnvelope { + #[serde(default)] + templates: Vec, +} + +#[derive(Debug, Deserialize)] +struct UserTemplateEnvelope { + template: CookieBotUserTemplate, +} + +#[derive(Debug, Deserialize)] +struct UserTemplateDeleted { + #[serde(default)] + deleted: bool, +} + +/// Every site list this user has saved, most recently edited first. +pub async fn list_user_templates() -> Result, CookieBotError> { + let envelope: UserTemplateListEnvelope = request( + reqwest::Method::GET, + format!("{}/user-templates", base()), + Vec::new(), + None, + TEMPLATE_CODES, + ) + .await?; + Ok(envelope.templates) +} + +/// Save a new one. +pub async fn create_user_template( + name: &str, + sites: &[String], +) -> Result { + let body = serde_json::json!({ "name": name, "sites": sites }); + let envelope: UserTemplateEnvelope = request( + reqwest::Method::POST, + format!("{}/user-templates", base()), + Vec::new(), + Some(body), + TEMPLATE_CODES, + ) + .await?; + Ok(envelope.template) +} + +/// Rename one, replace its sites, or both. +/// +/// A PATCH with only the fields that changed, because the two are independent: +/// a rename that had to carry the whole site list is a rename that silently +/// reverts an edit made to it from another device in the meantime. Sending an +/// omitted field as `null` would defeat that, so each is skipped when absent. +pub async fn update_user_template( + id: &str, + name: Option<&str>, + sites: Option<&[String]>, +) -> Result { + let mut body = serde_json::Map::new(); + if let Some(name) = name { + body.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + } + if let Some(sites) = sites { + body.insert("sites".to_string(), serde_json::json!(sites)); + } + + let envelope: UserTemplateEnvelope = request( + reqwest::Method::PATCH, + format!("{}/user-templates/{}", base(), urlencoding::encode(id)), + Vec::new(), + Some(serde_json::Value::Object(body)), + TEMPLATE_CODES, + ) + .await?; + Ok(envelope.template) +} + +/// Delete one. Enrolments that used it keep the sites they copied, so this is +/// never a way to stop a profile being warmed tonight. +/// +/// Safe to repeat: deleting a list that is already gone answers `false` rather +/// than 404, which is what makes a retry after a dropped response harmless. +pub async fn delete_user_template(id: &str) -> Result { + let deleted: UserTemplateDeleted = request( + reqwest::Method::DELETE, + format!("{}/user-templates/{}", base(), urlencoding::encode(id)), + Vec::new(), + None, + TEMPLATE_CODES, + ) + .await?; + Ok(deleted.deleted) +} + +// --- Tauri commands --------------------------------------------------------- +// +// The user-template commands live here rather than in `lib.rs` beside the +// schedule ones because they carry no local precondition: nothing about a saved +// site list depends on a profile this machine holds, so there is no profile to +// look up and no `bot_precondition` to apply. They must still be registered in +// `lib.rs`'s `invoke_handler` to be reachable. + +/// Log a refusal and hand the frontend the envelope it translates. +/// +/// The raw HTTP text never reaches the user: an untranslated backend sentence +/// in a Japanese UI is the failure the `{"code":…}` convention exists to stop. +fn command_error(context: &str, err: CookieBotError) -> String { + log::warn!( + "Cookie bot {context} failed: {} (HTTP {})", + err.code(), + err.status() + ); + err.to_error_json() +} + +/// Every site list this user has saved. +#[tauri::command] +pub async fn get_cookie_bot_user_templates() -> Result, String> { + list_user_templates() + .await + .map_err(|e| command_error("template list", e)) +} + +/// Save the current site list under a name. +#[tauri::command] +pub async fn create_cookie_bot_user_template( + name: String, + sites: Vec, +) -> Result { + create_user_template(&name, &sites) + .await + .map_err(|e| command_error("template create", e)) +} + +/// Rename a saved list, replace its sites, or both. Omitted fields are left +/// exactly as they are. +#[tauri::command] +pub async fn update_cookie_bot_user_template( + id: String, + name: Option, + sites: Option>, +) -> Result { + update_user_template(&id, name.as_deref(), sites.as_deref()) + .await + .map_err(|e| command_error("template update", e)) +} + +/// Delete a saved list. `false` means there was nothing left to delete. +#[tauri::command] +pub async fn delete_cookie_bot_user_template(id: String) -> Result { + delete_user_template(&id) + .await + .map_err(|e| command_error("template delete", e)) +} + /// Per-member and per-profile spend for a calendar month (`YYYY-MM`). pub async fn team_usage(period: Option<&str>) -> Result { let query = period @@ -976,6 +1365,7 @@ async fn request( mod tests { use super::*; use crate::profile::types::SyncMode; + use crate::remote_exit::ExitReachability; fn eligible_profile() -> BrowserProfile { BrowserProfile { @@ -1045,7 +1435,8 @@ mod tests { // that emptiness over the user's real profile. let mut profile = eligible_profile(); profile.sync_mode = SyncMode::Disabled; - let err = bot_precondition(&profile).expect_err("a local-only profile must be refused"); + let err = bot_precondition(&profile, &ExitReachability::Remote) + .expect_err("a local-only profile must be refused"); assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_CLOUD_SYNC"); } @@ -1055,7 +1446,8 @@ mod tests { // one code cannot carry two different instructions. let mut profile = eligible_profile(); profile.sync_mode = SyncMode::Encrypted; - let err = bot_precondition(&profile).expect_err("encrypted sync must be refused"); + let err = bot_precondition(&profile, &ExitReachability::Remote) + .expect_err("encrypted sync must be refused"); assert_eq!(code_of(&err), "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED"); } @@ -1063,7 +1455,8 @@ mod tests { fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() { let mut profile = eligible_profile(); profile.host_os = Some("linux".to_string()); - let err = bot_precondition(&profile).expect_err("linux has no host to lease"); + let err = bot_precondition(&profile, &ExitReachability::Remote) + .expect_err("linux has no host to lease"); let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope"); assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM"); assert_eq!( @@ -1076,7 +1469,8 @@ mod tests { fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() { let mut profile = eligible_profile(); profile.host_os = None; - let err = bot_precondition(&profile).expect_err("no OS means no matching host"); + let err = bot_precondition(&profile, &ExitReachability::Remote) + .expect_err("no OS means no matching host"); assert_eq!(code_of(&err), "COOKIE_BOT_UNKNOWN_PLATFORM"); } @@ -1087,7 +1481,8 @@ mod tests { let mut profile = eligible_profile(); profile.proxy_id = None; profile.vpn_id = None; - let err = bot_precondition(&profile).expect_err("datacenter egress must be refused"); + let err = bot_precondition(&profile, &ExitReachability::None) + .expect_err("datacenter egress must be refused"); assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE"); } @@ -1096,21 +1491,60 @@ mod tests { let mut profile = eligible_profile(); profile.proxy_id = None; profile.vpn_id = Some("vpn-1".to_string()); - assert!(bot_precondition(&profile).is_ok()); + assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok()); } #[test] fn a_windows_profile_with_sync_and_a_proxy_qualifies() { let mut profile = eligible_profile(); profile.host_os = Some("windows".to_string()); - assert!(bot_precondition(&profile).is_ok()); + assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok()); + } + + #[test] + fn an_exit_only_this_machine_can_reach_is_refused() { + // The gap `has_proxy` alone could never see, and — before the verdict became + // an argument — a case no unit test could construct, because resolving it + // reached into the global proxy store. The profile is otherwise perfect. + let profile = eligible_profile(); + + let err = bot_precondition( + &profile, + &ExitReachability::LocalOnly { + host: "127.0.0.1".to_string(), + source: "proxy", + }, + ) + .expect_err("a loopback exit cannot be dialled from a leased host"); + + // Its own code: "attach a proxy" is unactionable advice for someone whose + // proxy is plainly attached. + assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE"); + } + + #[test] + fn an_exit_we_could_not_read_is_refused_too() { + // Fails closed. Refusing a working setup costs one support question; + // accepting a broken one burns a leased hour and damages an identity. + let err = bot_precondition( + &eligible_profile(), + &ExitReachability::Unknown { + reason: "VPN config could not be parsed".to_string(), + source: "VPN", + }, + ) + .expect_err("an unreadable exit must not be assumed reachable"); + + assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE"); } /// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView` /// in donutbrowser-infra's `cookie-bot.service.ts` builds it. const SERVER_SCHEDULE_VIEW: &str = r#"{ "profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true, - "run_at_minute":120,"days_mask":127,"timezone":"Europe/Berlin", + "run_at_minute":120,"days_mask":127, + "slots":[{"days_mask":127,"run_at_minute":120},{"days_mask":31,"run_at_minute":690}], + "timezone":"Europe/Berlin","template_id":null, "preset":"balanced","max_minutes":45,"sites":["https://example.com"], "jitter_seconds":900,"sync_enabled":true,"encrypted_sync":false, "has_proxy":true,"touch_fingerprint":false,"sticky_exit":false, @@ -1142,6 +1576,132 @@ mod tests { assert!(schedule.blocked_by.is_none()); } + #[test] + fn a_schedule_carries_its_whole_calendar_not_just_the_first_time() { + // The mirrored pair is `slots[0]`, so a client that read only the pair + // would show "every night at 02:00" for an enrolment that also runs at + // 11:30 on weeknights — fewer runs than the user booked, silently. + let schedule: CookieBotSchedule = + serde_json::from_str(SERVER_SCHEDULE_VIEW).expect("a multi-slot schedule must deserialize"); + + assert_eq!(schedule.slots.len(), 2); + assert_eq!(schedule.slots[0].run_at_minute, schedule.run_at_minute); + assert_eq!(schedule.slots[0].days_mask, schedule.days_mask); + assert_eq!(schedule.slots[1].run_at_minute, 690); + assert_eq!(schedule.slots[1].days_mask, 31); + } + + #[test] + fn a_server_that_predates_multi_slot_still_decodes_with_no_slots() { + // `slots` absent is a deployment that has not rolled forward, not a broken + // enrolment. Requiring it would blank the whole Cookie Bot surface against + // an older backend rather than show the one time it does know about. + let schedule: CookieBotSchedule = serde_json::from_str( + r#"{"profile_id":"p1","profile_name":"Yu","platform":"windows","enabled":true, + "run_at_minute":120,"days_mask":31,"timezone":"UTC","preset":"light", + "max_minutes":10}"#, + ) + .expect("a pre-multi-slot schedule must deserialize"); + + assert!(schedule.slots.is_empty()); + assert!(schedule.template_id.is_none()); + } + + #[test] + fn a_templated_enrolment_reports_its_template_and_no_sites() { + // A built-in template's URLs are server-owned. An empty `sites` here is the + // contract working, not a schedule with nothing to browse — anything that + // reads it as "no sites" would show a healthy enrolment as broken. + let schedule: CookieBotSchedule = serde_json::from_str( + &SERVER_SCHEDULE_VIEW + .replace( + "\"template_id\":null", + "\"template_id\":\"low-intent-purchaser\"", + ) + .replace("\"sites\":[\"https://example.com\"]", "\"sites\":[]"), + ) + .expect("a templated schedule must deserialize"); + + assert_eq!( + schedule.template_id.as_deref(), + Some("low-intent-purchaser") + ); + assert!(schedule.sites.is_empty()); + assert!(schedule.blocked_by.is_none()); + } + + #[test] + fn a_calendar_is_sent_as_slots_and_omitted_entirely_when_there_is_none() { + // The server reads an ABSENT `slots` as "one slot, from the legacy pair" + // and REFUSES a null or empty one. Serialising `None` as null would 400 + // every write from a form with a single time on it. + let one_slot = CookieBotScheduleInput { + profile_name: "Yu".to_string(), + platform: "macos".to_string(), + enabled: true, + run_at_minute: 120, + days_mask: 127, + timezone: "Europe/Berlin".to_string(), + preset: "balanced".to_string(), + max_minutes: 45, + sites: vec!["https://example.com".to_string()], + ..Default::default() + }; + let encoded = serde_json::to_value(&one_slot).expect("input must serialize"); + assert!( + encoded.get("slots").is_none(), + "an absent calendar must be absent on the wire, not null" + ); + assert!(encoded.get("template_id").is_none()); + + let many = CookieBotScheduleInput { + slots: Some(vec![ + CookieBotSlot { + days_mask: 127, + run_at_minute: 120, + }, + CookieBotSlot { + days_mask: 31, + run_at_minute: 690, + }, + ]), + ..one_slot + }; + let encoded = serde_json::to_value(&many).expect("input must serialize"); + let slots = encoded["slots"].as_array().expect("slots must be a list"); + assert_eq!(slots.len(), 2); + // Mirrored, because a server that predates multi-slot ignores `slots` and + // stores this pair. Dropping it would leave that server with no time at all. + assert_eq!(encoded["run_at_minute"], 120); + assert_eq!(encoded["days_mask"], 127); + } + + #[test] + fn a_templated_write_names_the_template_and_sends_no_sites() { + // The server refuses a body carrying both: a curated persona merged with + // the user's own list is neither. + let input = CookieBotScheduleInput { + profile_name: "Yu".to_string(), + platform: "macos".to_string(), + enabled: true, + run_at_minute: 120, + days_mask: 127, + timezone: "UTC".to_string(), + preset: "balanced".to_string(), + max_minutes: 45, + sites: Vec::new(), + template_id: Some("low-intent-purchaser".to_string()), + ..Default::default() + }; + let encoded = serde_json::to_value(&input).expect("input must serialize"); + assert_eq!(encoded["template_id"], "low-intent-purchaser"); + assert_eq!( + encoded["sites"].as_array().map(Vec::len), + Some(0), + "sites must still be sent, and must be empty, beside a template" + ); + } + #[test] fn a_broken_enrolment_carries_the_reason_it_cannot_run() { // The whole point of `blocked_by`: a profile whose proxy was detached in @@ -1419,5 +1979,76 @@ mod tests { assert_eq!(presets.presets[0].id, "balanced"); assert_eq!(presets.presets[0].typical_minutes, Some(35)); assert_eq!(presets.default_preset.as_deref(), Some("balanced")); + // An older deployment sends neither of these, and the dialog has to render + // against it: no templates simply means the picker offers the user's own + // list, and no limits means the mirrored bounds apply. + assert!(presets.templates.is_empty()); + assert!(presets.limits.is_none()); + } + + #[test] + fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() { + // The pool is server-owned for the same reason a preset's browsing model + // is. If this type ever gained a `sites` field the curation would be + // published, and a published list is one a retailer can filter. + let presets: CookieBotPresetList = serde_json::from_str( + r#"{"presets":[],"default_preset":"balanced", + "templates":[{"id":"low-intent-purchaser","site_count":32, + "name":"Low-Intent Purchaser","description":"Price-sensitive browsing."}], + "limits":{"min_minutes":5,"max_minutes":120,"min_sites":1,"max_sites":40, + "max_site_length":2048,"max_jitter_seconds":3600,"max_slots":14, + "max_template_name_length":80}}"#, + ) + .expect("the preset list must carry templates and limits"); + + assert_eq!(presets.templates[0].id, "low-intent-purchaser"); + assert_eq!(presets.templates[0].site_count, 32); + let limits = presets.limits.expect("limits must decode"); + assert_eq!(limits.max_slots, Some(14)); + assert_eq!(limits.max_template_name_length, Some(80)); + assert_eq!(limits.max_sites, Some(40)); + } + + #[test] + fn a_saved_list_arrives_with_the_prefix_a_schedule_write_needs() { + // The id is what `template_id` takes verbatim. Handing the client a bare + // uuid and expecting it to prepend `user:` is how a saved list gets looked + // up against the built-in catalogue instead — which answers "no sites" and + // silently unschedules the profile. + let envelope: UserTemplateListEnvelope = serde_json::from_str( + r#"{"templates":[{"id":"user:1c9a…","name":"My shops", + "sites":["https://example.com"],"updated_at":"2026-08-05T10:00:00.000Z"}]}"#, + ) + .expect("the user template list must deserialize"); + + let template = &envelope.templates[0]; + assert!(template.id.starts_with("user:")); + assert_eq!(template.name, "My shops"); + assert_eq!(template.sites.len(), 1); + } + + #[test] + fn deleting_a_saved_list_that_is_already_gone_is_not_a_failure() { + // The route never 404s, so a delete retried after a dropped response has to + // read as "nothing left to do" rather than as an error the user must act on. + let deleted: UserTemplateDeleted = + serde_json::from_str(r#"{"deleted":false,"id":"user:gone"}"#) + .expect("a no-op delete must deserialize"); + assert!(!deleted.deleted); + } + + #[test] + fn a_template_404_is_a_missing_list_and_not_an_unenrolled_profile() { + // Sharing SCHEDULE_CODES here would tell someone renaming a site list that + // their profile is not enrolled, and a name collision that a teammate + // already warms the profile. + assert_eq!( + cloud_errors::classify_message("(404) Not Found", TEMPLATE_CODES).code, + "COOKIE_BOT_TEMPLATE_NOT_FOUND" + ); + assert_eq!( + cloud_errors::classify_message("(409) Conflict", TEMPLATE_CODES).code, + "COOKIE_BOT_TEMPLATE_NAME_TAKEN" + ); } } diff --git a/src-tauri/src/launch_gate.rs b/src-tauri/src/launch_gate.rs index 1e6729d..e637b7c 100644 --- a/src-tauri/src/launch_gate.rs +++ b/src-tauri/src/launch_gate.rs @@ -156,6 +156,9 @@ async fn enforce_direct_exit( profile: &BrowserProfile, gate: &FingerprintGate, ) -> Result<(), String> { + if gate_disabled() { + return Ok(()); + } if crate::launch_gate_prefs::fingerprint_ack_matches(profile, DIRECT_EXIT_IDENTITY) { return Ok(()); } @@ -207,22 +210,28 @@ pub async fn enforce_fingerprint_gate( if upstream.is_none() && !declares_route { return Ok(()); } - let Some(key) = fingerprint_consistency::exit_cache_key(profile) else { - // Declares a route we can no longer resolve at all (e.g. the stored proxy - // was deleted). Nothing identifies the exit, so measure the direct one. - if declares_route { - log::warn!( - "Fingerprint gate: {} declares a proxy/VPN that did not resolve; \ - measuring the direct exit it will actually use", - profile.name - ); - } - return enforce_direct_exit(profile, gate).await; - }; if gate_disabled() { return Ok(()); } + // Decide *once*, before any consent handling, whether this launch is going + // out directly. Both a route that no longer resolves (deleted proxy) and one + // that produced no usable upstream (a VPN worker with no local port) end up + // connecting directly, and both must mint and redeem consent under the same + // identity — splitting that decision across the function meant the first + // attempt minted under "direct" while the retry redeemed against the proxy + // identity, so "Launch anyway" could never succeed. + let key = fingerprint_consistency::exit_cache_key(profile); + if key.is_none() || upstream.is_none() { + log::warn!( + "Fingerprint gate: {} declares a proxy/VPN that yielded no usable upstream; \ + measuring the direct exit it will actually use", + profile.name + ); + return enforce_direct_exit(profile, gate).await; + } + let key = key.expect("checked above"); + // Ack first: a persisted acknowledgement already permits this launch, so a // stale token must not turn it into a hard failure. if crate::launch_gate_prefs::fingerprint_ack_matches(profile, &key.identity) { @@ -234,16 +243,6 @@ pub async fn enforce_fingerprint_gate( return Ok(()); } - // The route is known but produced no usable upstream (e.g. a VPN worker that - // came up without a local port). The browser still launches, direct. - if upstream.is_none() { - log::warn!( - "Fingerprint gate: {} has a route that yielded no upstream; measuring the direct exit", - profile.name - ); - return enforce_direct_exit(profile, gate).await; - } - let result = if matches!(gate, FingerprintGate::Advisory) { // Automation: answer from a warm cache or say nothing. Probing here would // add seconds to every profile in a batch run. @@ -390,9 +389,13 @@ pub async fn ack_launch_gate( let profile = load_profile(&profile_id)?; if ack_fingerprint { - if let Some(key) = fingerprint_consistency::exit_cache_key(&profile) { - crate::launch_gate_prefs::ack_fingerprint(&profile, &key.identity); - } + // Must match the identity the block was issued against. A profile whose + // route did not resolve is gated on the direct exit and has no cache key, + // so falling back here is what makes "don't block again" stick for it. + let identity = fingerprint_consistency::exit_cache_key(&profile) + .map(|key| key.identity) + .unwrap_or_else(|| DIRECT_EXIT_IDENTITY.to_string()); + crate::launch_gate_prefs::ack_fingerprint(&profile, &identity); } crate::launch_gate_prefs::ack_extensions(&profile_id, &ack_extension_keys); Ok(()) @@ -506,21 +509,32 @@ mod tests { #[tokio::test] async fn gate_allows_a_profile_with_no_proxy_or_vpn() { - // No exit identity means nothing to compare against, so the launch must - // proceed rather than block on an unmeasurable profile. + // A profile that declares no route has no upstream either — that pairing is + // the only one the launcher can actually produce. It must return without + // measuring anything, so this stays a pure unit test with no network. let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#); - let upstream = crate::browser::ProxySettings { - proxy_type: "socks5".into(), - host: "127.0.0.1".into(), - port: 1080, - username: None, - password: None, - vless_uri: None, - }; assert!( - enforce_fingerprint_gate(&profile, Some(&upstream), &FingerprintGate::Enforce) + enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce) .await .is_ok() ); } + + #[tokio::test] + async fn consent_for_a_direct_launch_is_redeemable_by_the_gate() { + // Regression: a route that yields no usable upstream is gated on the direct + // exit, so consent is minted under DIRECT_EXIT_IDENTITY. If the gate then + // redeemed against the proxy/VPN identity instead, "Launch anyway" would + // fail forever and the profile could never be started. + let mut profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#); + profile.vpn_id = Some("vpn-with-no-port".into()); + + let token = mint_consent(&profile, DIRECT_EXIT_IDENTITY); + // No upstream: the launcher could not bring the route up. + let result = enforce_fingerprint_gate(&profile, None, &FingerprintGate::Consented(token)).await; + assert!( + result.is_ok(), + "consent minted for the direct exit must be redeemable, got {result:?}" + ); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 82dffee..fa590d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,16 @@ pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Displ serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string() } +/// A VLESS URI Donut cannot use, carrying which part is unsupported so the UI +/// can say so instead of implying a typo. +pub(crate) fn vless_config_error(error: &crate::xray::XrayError) -> String { + serde_json::json!({ + "code": "VLESS_CONFIG_INVALID", + "params": { "reason": error.reason_code(), "detail": error.to_string() } + }) + .to_string() +} + fn e2e_automation_enabled() -> bool { #[cfg(feature = "e2e")] { @@ -75,6 +85,7 @@ mod proxy_manager; pub mod proxy_runner; pub mod proxy_server; pub mod proxy_storage; +mod remote_exit; mod remote_handoff; mod remote_session; mod settings_manager; @@ -84,6 +95,7 @@ mod synchronizer; pub mod traffic_stats; mod wayfern_manager; mod wayfern_terms; +mod window_decorations; // mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme pub mod cloud_auth; mod cloud_errors; @@ -317,6 +329,16 @@ async fn create_stored_proxy( } } +/// Validate a VLESS URI without touching the network, so the proxy form can +/// tell the user their setup is unsupported while they are still editing it +/// rather than only after they try to save or launch. +#[tauri::command] +fn validate_vless_uri(uri: String) -> Result<(), String> { + crate::xray::parse_vless_uri(uri.trim()) + .map(|_| ()) + .map_err(|error| vless_config_error(&error)) +} + #[tauri::command] async fn get_stored_proxies() -> Result, String> { Ok(crate::proxy_manager::PROXY_MANAGER.get_stored_proxies()) @@ -1447,7 +1469,7 @@ async fn save_cookie_bot_schedule( // Refused here rather than at 02:00: a profile that can never be warmed // should never reach a schedule row, an hour of quota or a leased host. let profile = cookie_bot_profile(&profile_id)?; - cookie_bot::bot_precondition(&profile)?; + cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?; // The frontend sends the user's choices; the profile facts the server refuses // a run on are stamped here, from the profile itself, so a caller cannot // assert them. @@ -1507,7 +1529,8 @@ async fn run_cookie_bot_now( profile_id: String, max_minutes: Option, ) -> Result { - cookie_bot::bot_precondition(&cookie_bot_profile(&profile_id)?)?; + let profile = cookie_bot_profile(&profile_id)?; + cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?; cookie_bot::run_now(&profile_id, max_minutes) .await .map_err(|e| cookie_bot_error("run start", e)) @@ -1769,7 +1792,12 @@ pub fn run_with_builder( .with_state_flags( tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE - & !tauri_plugin_window_state::StateFlags::FULLSCREEN, + & !tauri_plugin_window_state::StateFlags::FULLSCREEN + // Whether the window is decorated is decided per-session by + // `window_decorations::use_client_side_decorations()`, not by what + // a previous run saved. Restoring it would put a real titlebar back + // on top of the one the app draws — or strip both. + & !tauri_plugin_window_state::StateFlags::DECORATIONS, ) .build(), ); @@ -1809,9 +1837,21 @@ pub fn run_with_builder( None => win_builder, }; + // The app draws its own titlebar. macOS keeps the native one and makes + // it transparent (below); Windows and Linux drop decorations entirely and + // render their own controls. #[cfg(target_os = "windows")] let win_builder = win_builder.decorations(false); + // Linux opts out on the one configuration where dropping decorations can + // make things worse rather than better — see `use_client_side_decorations`. + #[cfg(target_os = "linux")] + let win_builder = if window_decorations::use_client_side_decorations() { + win_builder.decorations(false) + } else { + win_builder + }; + #[allow(unused_variables)] let window = win_builder.build().unwrap(); @@ -1844,6 +1884,44 @@ pub fn run_with_builder( }); } + // Publish the desktop's titlebar button layout to the frontend. Runs + // here because `setup` is the GTK main thread, which `gtk::Settings` + // requires. + // + // The decorated state is logged alongside it: "my window has no titlebar" + // and "my window has two titlebars" are both reports that hinge on this + // one boolean, and it is otherwise invisible after the fact. + #[cfg(target_os = "linux")] + { + log::info!( + "Linux window decorations: server-side = {:?}", + window.is_decorated() + ); + + // tao makes the window visible before it clears the decorations, so it + // is realized while still framed and the frame extents come out of the + // size we asked for (a requested 880x500 arrives noticeably smaller). + // + // Only correct that on a first run. Once window-state has geometry + // saved, that geometry is the user's and has already been restored — + // re-applying the default here would move and resize their window on + // every launch, and the plugin would then persist the reset. + let has_saved_geometry = app + .path() + .app_config_dir() + .map(|dir| dir.join(".window-state.json").exists()) + .unwrap_or(false); + if window_decorations::use_client_side_decorations() && !has_saved_geometry { + if let Err(e) = window.set_size(tauri::LogicalSize::new(880.0, 500.0)) { + log::warn!("Failed to re-apply the window size after dropping decorations: {e}"); + } + if let Err(e) = window.center() { + log::warn!("Failed to re-center the window after dropping decorations: {e}"); + } + } + } + window_decorations::init(app.handle()); + // Set transparent titlebar for macOS #[cfg(target_os = "macos")] { @@ -2679,6 +2757,8 @@ pub fn run_with_builder( fingerprint_consistency::match_profile_fingerprint_to_exit, launch_gate::get_profile_pre_launch_checks, launch_gate::ack_launch_gate, + window_decorations::get_window_decoration_layout, + validate_vless_uri, get_sync_settings, save_sync_settings, set_profile_sync_mode, @@ -2775,6 +2855,14 @@ pub fn run_with_builder( get_cookie_bot_presets, get_remote_hours_quota, get_cookie_bot_usage, + // Defined in `cookie_bot.rs` rather than here because they carry no local + // precondition — there is no profile to look up and no `bot_precondition` + // to apply. Unregistered they are unreachable, and the saved-list tab + // fails at runtime with "command not found" rather than at build time. + cookie_bot::get_cookie_bot_user_templates, + cookie_bot::create_cookie_bot_user_template, + cookie_bot::update_cookie_bot_user_template, + cookie_bot::delete_cookie_bot_user_template, // Profile password commands set_profile_password, change_profile_password, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index f626fd7..71d06a7 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -2341,11 +2341,10 @@ impl McpServer { "check_cookie_bot_conflicts" => Self::handle_check_cookie_bot_conflicts(arguments).await, "list_cookie_bot_runs" => Self::handle_list_cookie_bot_runs(arguments).await, "run_cookie_bot_now" => { - Self::require_capability( - "Browser automation", - CLOUD_AUTH.can_use_browser_automation().await, - ) - .await?; + // The Cookie Bot, NOT browser automation. Solo pays for the bot and has + // no automation; gating this on automation refused a Solo customer the + // feature their plan is sold on while their scheduled runs kept firing. + Self::require_capability("Cookie Bot", CLOUD_AUTH.can_use_cookie_bot().await).await?; Self::handle_run_cookie_bot_now(arguments).await } // No capability gate on the cancel. A lapsed plan must never be the @@ -5730,10 +5729,11 @@ impl McpServer { message: format!("Profile not found: {profile_id}"), })?; - crate::cookie_bot::bot_precondition(&profile).map_err(|message| McpError { - code: -32000, - message, - })?; + crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile)) + .map_err(|message| McpError { + code: -32000, + message, + })?; Ok(profile) } @@ -6177,19 +6177,28 @@ mod tests { ..Default::default() }; - assert!(crate::cookie_bot::bot_precondition(&eligible()).is_ok()); + assert!(crate::cookie_bot::bot_precondition( + &eligible(), + &crate::remote_exit::ExitReachability::Remote + ) + .is_ok()); let mut local_only = eligible(); local_only.sync_mode = SyncMode::Disabled; assert!( - crate::cookie_bot::bot_precondition(&local_only).is_err(), + crate::cookie_bot::bot_precondition( + &local_only, + &crate::remote_exit::ExitReachability::Remote + ) + .is_err(), "a profile with no cloud copy has nothing for a host to open" ); let mut linux = eligible(); linux.host_os = Some("linux".to_string()); assert!( - crate::cookie_bot::bot_precondition(&linux).is_err(), + crate::cookie_bot::bot_precondition(&linux, &crate::remote_exit::ExitReachability::Remote) + .is_err(), "the fleet cannot lease a linux host" ); @@ -6197,7 +6206,11 @@ mod tests { datacenter_egress.proxy_id = None; datacenter_egress.vpn_id = None; assert!( - crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(), + crate::cookie_bot::bot_precondition( + &datacenter_egress, + &crate::remote_exit::ExitReachability::None + ) + .is_err(), "hours of traffic from a hosting ASN damages the identity being warmed" ); } diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 167ca68..202a7e0 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -464,10 +464,10 @@ impl ProxyManager { .as_deref() .filter(|uri| !uri.is_empty()) .ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?; - let parsed = crate::xray::parse_vless_uri(uri) - .map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?; + let parsed = + crate::xray::parse_vless_uri(uri).map_err(|error| crate::vless_config_error(&error))?; let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref()) - .map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?; + .map_err(|error| crate::vless_config_error(&error))?; proxy_settings.proxy_type = "vless".to_string(); proxy_settings.host = parsed.config.address; diff --git a/src-tauri/src/remote_exit.rs b/src-tauri/src/remote_exit.rs new file mode 100644 index 0000000..fcc6c49 --- /dev/null +++ b/src-tauri/src/remote_exit.rs @@ -0,0 +1,511 @@ +//! Whether a profile's exit node can be reached from somewhere that is not this +//! machine. +//! +//! Remote execution — an interactive remote session or a Cookie Bot night — runs +//! the browser on a leased fleet host, but the PROFILE (and its proxy, and its +//! VPN config) is pulled from the user's sync namespace. Nothing in that +//! handover rewrites addresses, so a proxy recorded as `127.0.0.1:8080` arrives +//! on the fleet host meaning *the fleet host's own loopback*. +//! +//! That is the whole bug this module exists to prevent. The server already +//! refuses a profile with NO exit (`proxy_required`), because a night browsed +//! from the fleet's datacenter address damages an identity rather than building +//! it — but it was asking whether an exit was *configured*, never whether it was +//! *reachable*. A local proxy satisfied the first question and failed the +//! second, so the run was accepted, dispatched, and burned a leased host either +//! erroring out or (worse) egressing direct from the datacenter: exactly the +//! outcome `proxy_required` exists to stop, reached by the one route it did not +//! check. +//! +//! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a +//! locally-run SOCKS client and Donut's own VLESS support all present to the +//! browser as `127.0.0.1:`. +//! +//! This module is the single answer, shared by every caller, and it FAILS +//! CLOSED: anything it cannot parse is reported as unreachable. Refusing a +//! working setup costs the user one support question; accepting a broken one +//! costs a burned hour and a damaged profile identity. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +/// Whether a leased fleet host could dial this profile's exit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExitReachability { + /// No proxy and no VPN. The caller's existing "no exit" refusal applies. + None, + /// An address a host elsewhere on the internet can reach. + Remote, + /// An address that only means anything on this machine or this LAN. + LocalOnly { + /// The offending host, for a message the user can act on. + host: String, + /// Which part of the config it came from: "proxy" or "VPN". + source: &'static str, + }, + /// Configured, but this code could not determine the host. + /// + /// Treated as unreachable by [`ExitReachability::is_remote`] — see the + /// fail-closed note in the module docs. + Unknown { + reason: String, + source: &'static str, + }, +} + +impl ExitReachability { + /// Whether remote execution may proceed. + pub fn is_remote(&self) -> bool { + matches!(self, ExitReachability::Remote) + } + + /// A one-line reason for a refusal, or None when there is nothing to refuse. + pub fn refusal_detail(&self) -> Option { + match self { + ExitReachability::Remote | ExitReachability::None => None, + ExitReachability::LocalOnly { host, source } => Some(format!( + "The {source} for this profile points at {host}, which only exists on this computer. \ + Remote runs happen on our hosts and cannot reach it." + )), + ExitReachability::Unknown { reason, source } => Some(format!( + "The {source} for this profile could not be read ({reason}), so we cannot confirm a \ + remote host could use it." + )), + } + } +} + +/// Whether a hostname or IP literal is reachable from another machine. +/// +/// Rejects, in order: empty/whitespace, unparsable-as-either, and every IP +/// range that is scoped to a machine or a private network. Hostnames that are +/// not IP literals are accepted unless they use a name suffix that is +/// definitionally local — a public DNS name cannot be validated here without a +/// lookup, and doing a lookup would make this impure and slow on a hot path. +pub fn host_is_remote_reachable(host: &str) -> bool { + let host = normalize_host(host); + if host.is_empty() { + return false; + } + + if let Ok(ip) = host.parse::() { + return ip_is_remote_reachable(ip); + } + + let lower = host.to_ascii_lowercase(); + + // `localhost` and anything under it resolve to loopback everywhere. + if lower == "localhost" || lower.ends_with(".localhost") { + return false; + } + + // Suffixes reserved for local/private name resolution (RFC 6762 mDNS, RFC + // 8375, and the names router vendors hand out on a LAN). A fleet host + // resolving one of these gets its own network's answer, not the user's. + const LOCAL_SUFFIXES: [&str; 7] = [ + ".local", + ".localdomain", + ".internal", + ".home", + ".home.arpa", + ".lan", + ".intranet", + ]; + if LOCAL_SUFFIXES.iter().any(|suffix| lower.ends_with(suffix)) { + return false; + } + + // A bare single-label name ("my-proxy", "router") is only resolvable through + // a local search domain, so it is no more use to a fleet host than `.local`. + if !lower.contains('.') { + return false; + } + + true +} + +/// Whether an IP literal is routable from another machine. +fn ip_is_remote_reachable(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => ipv4_is_remote_reachable(v4), + IpAddr::V6(v6) => ipv6_is_remote_reachable(v6), + } +} + +fn ipv4_is_remote_reachable(ip: Ipv4Addr) -> bool { + // `is_private`/`is_loopback`/`is_link_local` are stable; the rest are not, so + // the remaining ranges are spelled out rather than gated behind a nightly + // feature. + if ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() { + return false; + } + if ip.is_broadcast() || ip.is_multicast() || ip.is_documentation() { + return false; + } + let [a, b, ..] = ip.octets(); + // 100.64.0.0/10 — carrier-grade NAT (RFC 6598). Reachable inside one + // carrier's network and nowhere else. + if a == 100 && (64..128).contains(&b) { + return false; + } + // 0.0.0.0/8 "this network", and 240.0.0.0/4 reserved. + if a == 0 || a >= 240 { + return false; + } + true +} + +fn ipv6_is_remote_reachable(ip: Ipv6Addr) -> bool { + if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() { + return false; + } + // An IPv4 address wearing an IPv6 hat is still that IPv4 address — classify + // it as one, or `::ffff:127.0.0.1` walks straight through. + if let Some(v4) = ip.to_ipv4_mapped() { + return ipv4_is_remote_reachable(v4); + } + if let Some(v4) = ip.to_ipv4() { + return ipv4_is_remote_reachable(v4); + } + let segments = ip.segments(); + // fc00::/7 unique-local, fe80::/10 link-local. + if (segments[0] & 0xfe00) == 0xfc00 { + return false; + } + if (segments[0] & 0xffc0) == 0xfe80 { + return false; + } + true +} + +/// Strip the decoration a host can arrive wrapped in: whitespace, `[...]` +/// around an IPv6 literal, a trailing dot on an FQDN, and any `user@` or +/// `:port` that came along from a URI. +fn normalize_host(raw: &str) -> String { + let mut host = raw.trim(); + if host.is_empty() { + return String::new(); + } + + // `user:pass@host` — take what follows the LAST '@', since a password may + // itself contain one. + if let Some(at) = host.rfind('@') { + host = &host[at + 1..]; + } + + // Bracketed IPv6, optionally with a port: `[::1]:1080`. + if let Some(stripped) = host.strip_prefix('[') { + if let Some(end) = stripped.find(']') { + return stripped[..end].trim().to_string(); + } + return stripped.trim().to_string(); + } + + // `host:port`, but only when there is exactly one colon — more than one means + // a bare IPv6 literal, whose colons are part of the address. + if host.matches(':').count() == 1 { + if let Some((left, _port)) = host.split_once(':') { + host = left; + } + } + + host.trim().trim_end_matches('.').to_string() +} + +/// The host a VLESS URI actually dials. +/// +/// Load-bearing because of an asymmetry that is easy to get backwards: a VLESS +/// proxy presents to the browser as `127.0.0.1:` — Donut runs a local xray +/// worker and points the browser at it — but the address that decides whether +/// anyone else could use this config is the SERVER inside the URI. The local +/// port is an implementation detail of this machine; the URI is the exit. +pub fn vless_uri_host(uri: &str) -> Option { + let rest = uri.trim().strip_prefix("vless://")?; + // Cut the fragment (`#label`) and query (`?type=...`) before looking for the + // authority — either may contain '@' or ':'. + let rest = rest.split('#').next()?; + let rest = rest.split('?').next()?; + // `uuid@host:port/...` + let authority = rest.split('/').next()?; + let host_port = authority + .rsplit_once('@') + .map(|(_, h)| h) + .unwrap_or(authority); + let host = normalize_host(host_port); + if host.is_empty() { + None + } else { + Some(host) + } +} + +/// The exit host a stored proxy represents, as a remote host would have to dial +/// it. +pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result { + if settings.proxy_type.eq_ignore_ascii_case("vless") { + let uri = settings + .vless_uri + .as_deref() + .filter(|uri| !uri.trim().is_empty()) + .ok_or_else(|| "VLESS proxy has no server URI".to_string())?; + return vless_uri_host(uri).ok_or_else(|| "VLESS server URI is malformed".to_string()); + } + + let host = normalize_host(&settings.host); + if host.is_empty() { + return Err("proxy has no host".to_string()); + } + Ok(host) +} + +/// Classify a stored proxy. +pub fn classify_proxy(settings: &crate::browser::ProxySettings) -> ExitReachability { + match proxy_exit_host(settings) { + Err(reason) => ExitReachability::Unknown { + reason, + source: "proxy", + }, + Ok(host) => { + if host_is_remote_reachable(&host) { + ExitReachability::Remote + } else { + ExitReachability::LocalOnly { + host, + source: "proxy", + } + } + } + } +} + +/// Classify a WireGuard peer endpoint (`host:port`). +pub fn classify_wireguard_endpoint(peer_endpoint: &str) -> ExitReachability { + let host = normalize_host(peer_endpoint); + if host.is_empty() { + return ExitReachability::Unknown { + reason: "VPN config has no peer endpoint".to_string(), + source: "VPN", + }; + } + if host_is_remote_reachable(&host) { + ExitReachability::Remote + } else { + ExitReachability::LocalOnly { + host, + source: "VPN", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::browser::ProxySettings; + + fn proxy(proxy_type: &str, host: &str) -> ProxySettings { + ProxySettings { + proxy_type: proxy_type.to_string(), + host: host.to_string(), + port: 8080, + username: None, + password: None, + vless_uri: None, + } + } + + #[test] + fn loopback_in_every_spelling_is_local() { + // The literal case the bug was reported for, plus the spellings that reach + // the same place. `::ffff:127.0.0.1` is the one a naive IPv6 check misses. + for host in [ + "127.0.0.1", + "127.1.2.3", + "localhost", + "LOCALHOST", + "foo.localhost", + "::1", + "[::1]", + "::ffff:127.0.0.1", + "0.0.0.0", + "::", + ] { + assert!( + !host_is_remote_reachable(host), + "{host} should not be remote-reachable" + ); + } + } + + #[test] + fn private_and_carrier_ranges_are_local() { + for host in [ + "10.0.0.1", + "192.168.1.1", + "172.16.0.1", + "172.31.255.254", + "169.254.1.1", // link-local / APIPA + "100.64.0.1", // CGNAT + "100.127.255.1", + "fd00::1", // unique-local + "fe80::1", // link-local + "240.0.0.1", + "0.1.2.3", + ] { + assert!( + !host_is_remote_reachable(host), + "{host} should not be remote-reachable" + ); + } + } + + #[test] + fn public_addresses_and_names_are_reachable() { + for host in [ + "1.1.1.1", + "8.8.8.8", + "172.15.0.1", // just outside 172.16/12 + "172.32.0.1", + "100.63.255.255", // just outside 100.64/10 + "100.128.0.1", + "2606:4700:4700::1111", + "proxy.example.com", + "gate.smartproxy.net.", + "residential.example.co.uk", + ] { + assert!( + host_is_remote_reachable(host), + "{host} should be remote-reachable" + ); + } + } + + #[test] + fn lan_only_names_are_local() { + // A fleet host resolving these gets ITS network's answer, not the user's — + // which is worse than failing, because it may well succeed against + // something unrelated. + for host in [ + "my-proxy", // single label: needs a search domain + "router.local", + "nas.home.arpa", + "proxy.lan", + "box.internal", + "server.localdomain", + "gateway.intranet", + ] { + assert!( + !host_is_remote_reachable(host), + "{host} should not be remote-reachable" + ); + } + } + + #[test] + fn host_port_and_credentials_are_stripped_before_classifying() { + assert!(!host_is_remote_reachable("127.0.0.1:8080")); + assert!(!host_is_remote_reachable("user:pass@127.0.0.1:8080")); + assert!(!host_is_remote_reachable("[::1]:1080")); + assert!(host_is_remote_reachable("user:p@ss@proxy.example.com:8080")); + } + + #[test] + fn a_vless_proxy_is_judged_by_its_server_not_its_local_port() { + // THE asymmetry. Donut points the browser at a local xray worker, so the + // browser-facing address of every VLESS proxy is 127.0.0.1 — but the stored + // config names a real server, and that is what a fleet host would dial. + // Classifying VLESS off `settings.host` would refuse every VLESS profile. + let mut settings = proxy("vless", "127.0.0.1"); + settings.vless_uri = + Some("vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?type=tcp#node".into()); + + assert_eq!(classify_proxy(&settings), ExitReachability::Remote); + } + + #[test] + fn a_vless_uri_pointing_at_loopback_is_still_local() { + let mut settings = proxy("vless", "127.0.0.1"); + settings.vless_uri = Some("vless://uuid@127.0.0.1:443?type=tcp".into()); + + assert_eq!( + classify_proxy(&settings), + ExitReachability::LocalOnly { + host: "127.0.0.1".to_string(), + source: "proxy", + } + ); + } + + #[test] + fn vless_host_parsing_survives_query_and_fragment() { + assert_eq!( + vless_uri_host("vless://uuid@example.com:443?sni=a@b.com&x=1#my@label"), + Some("example.com".to_string()) + ); + assert_eq!( + vless_uri_host("vless://uuid@[2606:4700::1111]:443?type=ws"), + Some("2606:4700::1111".to_string()) + ); + assert_eq!(vless_uri_host("not-a-vless-uri"), None); + } + + #[test] + fn an_unreadable_config_fails_closed() { + // Unknown must never be treated as usable: the point of the check is that + // we could not confirm reachability, and guessing "yes" reintroduces the + // exact failure it prevents. + let mut settings = proxy("vless", ""); + settings.vless_uri = None; + let verdict = classify_proxy(&settings); + + assert!(matches!(verdict, ExitReachability::Unknown { .. })); + assert!(!verdict.is_remote()); + assert!(verdict.refusal_detail().is_some()); + } + + #[test] + fn ordinary_proxies_are_classified_by_host() { + assert_eq!( + classify_proxy(&proxy("socks5", "gate.example.com")), + ExitReachability::Remote + ); + assert_eq!( + classify_proxy(&proxy("http", "192.168.0.10")), + ExitReachability::LocalOnly { + host: "192.168.0.10".to_string(), + source: "proxy", + } + ); + } + + #[test] + fn wireguard_endpoints_are_classified_by_their_peer() { + assert_eq!( + classify_wireguard_endpoint("vpn.example.com:51820"), + ExitReachability::Remote + ); + assert_eq!( + classify_wireguard_endpoint("10.0.0.1:51820"), + ExitReachability::LocalOnly { + host: "10.0.0.1".to_string(), + source: "VPN", + } + ); + assert!(matches!( + classify_wireguard_endpoint(" "), + ExitReachability::Unknown { .. } + )); + } + + #[test] + fn only_remote_permits_a_run() { + assert!(ExitReachability::Remote.is_remote()); + assert!(!ExitReachability::None.is_remote()); + assert!(!ExitReachability::LocalOnly { + host: "127.0.0.1".into(), + source: "proxy" + } + .is_remote()); + // `None` has no detail: the caller's existing "no exit at all" refusal is + // the better message, and two refusals for one condition read as a bug. + assert!(ExitReachability::None.refusal_detail().is_none()); + } +} diff --git a/src-tauri/src/remote_session.rs b/src-tauri/src/remote_session.rs index 2113c8f..fae1ed6 100644 --- a/src-tauri/src/remote_session.rs +++ b/src-tauri/src/remote_session.rs @@ -8,6 +8,7 @@ use crate::cloud_errors::{self, FailureCodes}; use crate::profile::types::BrowserProfile; +use crate::remote_exit::ExitReachability; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; @@ -115,6 +116,54 @@ pub fn idempotency_key(profile_id: &str, attempt: &str) -> String { format!("run-remote:{profile_id}:{attempt}") } +/// Whether this profile's exit rules out running it on a leased host. +/// +/// A session runs on a fleet host that pulls the profile — and its proxy record +/// — out of the user's sync namespace, rewriting no addresses along the way. A +/// proxy stored as `127.0.0.1:8080` therefore arrives meaning THAT host's +/// loopback: the browser either cannot connect and the leased hour is burned on +/// a session that never worked, or it falls through and the user's identity +/// egresses from our datacenter. The Cookie Bot has refused this since +/// `remote_exit` existed; interactive sessions take the same profile onto the +/// same hosts and did not, so the same mistake cost a leased hour here. +/// +/// A profile with NO exit at all is deliberately allowed through. The Cookie +/// Bot refuses that separately because a night of unattended browsing from a +/// hosting ASN damages an identity, but an interactive session is a person at a +/// keyboard who chose to open this profile and can see where it comes out — +/// and no rule has ever required an exit here. Refusing it would be a new +/// product restriction wearing this bug's error code. +/// +/// Split out from the launch because that is the only testable seam: +/// `exit_reachability` reads this machine's proxy and VPN stores and the launch +/// itself needs a fleet. +fn local_exit_refusal(verdict: &ExitReachability) -> Option { + match verdict { + // An address anyone can dial, so the leased host can dial it too. + ExitReachability::Remote => None, + // See the second paragraph above: allowed on purpose, not overlooked. + ExitReachability::None => None, + // `LocalOnly`, plus `Unknown` — which `remote_exit` produces when it could + // not read the config and which fails closed by design, because "we could + // not confirm it" guessed as "yes" is the failure this whole check exists + // to stop. + unusable => { + // The prose names the offending host, which belongs in the log where + // support can read it. The toast gets the code so it stays translated. + if let Some(detail) = unusable.refusal_detail() { + log::warn!("Refusing an interactive remote session: {detail}"); + } + // `Other` rather than a typed variant: the other three are each pinned to + // a status and a meaning — "the fleet is busy", "already open somewhere", + // "not on your plan" — and this refusal is none of them. The code in the + // body is what every surface renders. + Some(RemoteSessionError::Other( + serde_json::json!({ "code": "REMOTE_REQUIRES_REMOTE_EXIT_NODE" }).to_string(), + )) + } + } +} + /// Ask donutbrowser-infra to start a remote session for this profile. /// /// Goes through `api_call_with_retry` so an expired access token is refreshed @@ -133,6 +182,14 @@ pub async fn start_remote_session( .to_string(); let profile_id = profile.id.to_string(); + // Checked here, before the request: the backend is told which profile to + // start but never sees the proxy record, so it cannot derive this — and by + // the time it could, an hour is already leased and billed. Resolving a proxy + // id to an address is only possible on the machine that stores it. + if let Some(refusal) = local_exit_refusal(&crate::cookie_bot::exit_reachability(profile)) { + return Err(refusal); + } + // One key for this user action: a retry inside api_call_with_retry must // de-duplicate rather than open a second browser on the same profile. let key = idempotency_key(&profile_id, &uuid::Uuid::new_v4().to_string()); @@ -1180,6 +1237,50 @@ mod tests { ); } + #[test] + fn a_local_only_exit_is_refused_before_a_host_is_leased() { + // The profile and its proxy record are copied onto the fleet unrewritten, + // so this loopback address would mean the FLEET's loopback. Accepting the + // launch bills an hour for a session that cannot reach the user's exit. + let refusal = local_exit_refusal(&ExitReachability::LocalOnly { + host: "127.0.0.1".to_string(), + source: "proxy", + }) + .expect("a loopback proxy is unusable from a leased host"); + + assert_eq!( + refusal.to_error_json(), + r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"# + ); + } + + #[test] + fn an_exit_that_could_not_be_read_is_refused_too() { + // `Unknown` is "we could not confirm this works from elsewhere". Treating + // that as a yes reintroduces exactly the burned hour above, so it fails + // closed here as it does everywhere else `remote_exit` is consulted. + let refusal = local_exit_refusal(&ExitReachability::Unknown { + reason: "the profile references a proxy that no longer exists".to_string(), + source: "proxy", + }) + .expect("an unreadable exit is not evidence of a reachable one"); + + assert_eq!( + refusal.to_error_json(), + r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"# + ); + } + + #[test] + fn a_reachable_exit_and_no_exit_at_all_are_both_allowed_to_launch() { + assert!(local_exit_refusal(&ExitReachability::Remote).is_none()); + // Deliberate, and the reason this gate is not simply `!is_remote()`: a + // proxyless interactive session has always been permitted, and refusing it + // with a code that says "your proxy is local" would be both a new product + // rule and a sentence that does not describe the profile. + assert!(local_exit_refusal(&ExitReachability::None).is_none()); + } + #[test] fn a_backend_supplied_code_survives_the_trip_through_the_typed_error() { // Once infra sends an envelope, its code must win over the status default diff --git a/src-tauri/src/vpn_extension_detect/browser_scan.rs b/src-tauri/src/vpn_extension_detect/browser_scan.rs index 0478317..10471ec 100644 --- a/src-tauri/src/vpn_extension_detect/browser_scan.rs +++ b/src-tauri/src/vpn_extension_detect/browser_scan.rs @@ -22,6 +22,9 @@ const MAX_EXTENSION_DIRS: usize = 300; const MAX_MANIFEST_BYTES: u64 = 512 * 1024; /// Wall-clock ceiling for the whole scan. This runs on the launch path. const SCAN_DEADLINE: Duration = Duration::from_millis(750); +/// Chromium preference files are larger than a manifest but still bounded; a +/// pathological one must not be parsed while the user waits for a browser. +const MAX_PREFERENCES_BYTES: u64 = 32 * 1024 * 1024; /// Chromium profile directories to search inside a user-data dir. /// @@ -69,6 +72,19 @@ fn read_json_file(path: &Path, max_bytes: Option) -> Option bool { + !name.is_empty() + && name.len() <= 32 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + fn resolve_dir_i18n( version_dir: &Path, manifest: &serde_json::Value, @@ -76,6 +92,10 @@ fn resolve_dir_i18n( ) -> Option { let key = message_placeholder_key(value)?; let default_locale = manifest.get("default_locale")?.as_str()?; + if !is_safe_locale_name(default_locale) { + log::warn!("Ignoring extension with a suspicious default_locale: {default_locale:?}"); + return None; + } let messages = read_json_file( &version_dir .join("_locales") @@ -105,8 +125,9 @@ struct PreferenceExtensions { fn preference_extensions(profile_dir: &Path) -> PreferenceExtensions { let mut out = PreferenceExtensions::default(); for file in ["Secure Preferences", "Preferences"] { - // Preference files are legitimately large, so they skip the manifest cap. - let Some(prefs) = read_json_file(&profile_dir.join(file), None) else { + // Larger than a manifest, but still capped: this is parsed while the user + // waits for a browser to start. + let Some(prefs) = read_json_file(&profile_dir.join(file), Some(MAX_PREFERENCES_BYTES)) else { continue; }; let Some(settings) = prefs @@ -150,6 +171,9 @@ pub(super) fn scan_browser_extensions( // Read preferences first: unpacked extensions live outside Extensions/, so // a profile that has only sideloaded ones has no Extensions/ dir at all and // must not be skipped before they are considered. + if started.elapsed() > SCAN_DEADLINE { + return false; + } let prefs = preference_extensions(&profile_dir); let disabled = &prefs.disabled; @@ -566,6 +590,41 @@ mod tests { assert!(out.is_empty()); } + #[test] + fn a_traversing_default_locale_is_refused() { + // `default_locale` comes from a manifest we did not write. Joined naively + // it reads any file the user can read, on the launch path. + assert!(!is_safe_locale_name("../../../../etc")); + assert!(!is_safe_locale_name("..\\..\\windows")); + assert!(!is_safe_locale_name("/etc/passwd")); + assert!(!is_safe_locale_name("")); + assert!(is_safe_locale_name("en")); + assert!(is_safe_locale_name("en_GB")); + assert!(is_safe_locale_name("zh-CN")); + } + + #[test] + fn a_localized_name_with_a_traversing_locale_is_not_resolved() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let version_dir = root + .join("Default") + .join("Extensions") + .join(CRX_ID) + .join("2.1.0_0"); + write( + &version_dir.join("manifest.json"), + r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"../../../../etc","permissions":["proxy"]}"#, + ); + + let mut out = Vec::new(); + assert!(scan_browser_extensions(root, &mut out, Instant::now())); + // Still detected (the proxy permission is what matters), but the name + // falls back rather than the traversal being followed. + assert_eq!(out.len(), 1); + assert_eq!(out[0].name, CRX_ID); + } + #[test] fn scan_tolerates_a_missing_user_data_dir() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/vpn_extension_detect/mod.rs b/src-tauri/src/vpn_extension_detect/mod.rs index 7e614e1..47e7c0d 100644 --- a/src-tauri/src/vpn_extension_detect/mod.rs +++ b/src-tauri/src/vpn_extension_detect/mod.rs @@ -142,14 +142,13 @@ pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan { "partial" }; - // The two sources are disjoint by construction, but an imported profile can - // carry its own copy of an extension Donut also manages. Collapse only on an - // exact name+version match, and never on an unresolved `__MSG_` placeholder — - // those are not identities and would fold unrelated extensions into one row. + // Collapse only exact duplicates of the same extension. `key` is the real + // identity (`donut:` / `crx:`); name+version is not, and two + // distinct extensions sharing a display name would silently fold into one — + // dropping a `confirmed` detection would then flip `has_confirmed()` and stop + // the gate treating its own exit measurement as unreliable. let mut seen = HashSet::new(); - extensions.retain(|e| { - message_placeholder_key(&e.name).is_some() || seen.insert((e.name.clone(), e.version.clone())) - }); + extensions.retain(|e| seen.insert(e.key.clone())); ExtensionScan { extensions, diff --git a/src-tauri/src/window_decorations.rs b/src-tauri/src/window_decorations.rs new file mode 100644 index 0000000..57705f0 --- /dev/null +++ b/src-tauri/src/window_decorations.rs @@ -0,0 +1,182 @@ +//! Client-side window decorations on Linux. +//! +//! The app draws its own titlebar (as it already does on macOS and Windows), so +//! the window is built without server-side decorations. That means the app also +//! owns the window *controls*, and their side and order are a desktop-wide user +//! preference that differs between environments — GNOME defaults to +//! `:minimize,maximize,close` (all on the right), and a user who has moved them +//! to the left expects every app to follow. +//! +//! `GtkSettings::gtk-decoration-layout` is the one place every desktop +//! publishes that preference to GTK applications: GNOME mirrors +//! `org.gnome.desktop.wm.preferences button-layout` into it, and on KDE Plasma +//! `kde-gtk-config` mirrors KWin's decoration button configuration into it. +//! Reading this property therefore gets both environments right without any +//! desktop-specific branching. + +use serde::Serialize; + +/// Whether this window draws its own titlebar, and how. +#[derive(Debug, Clone, Serialize)] +pub struct WindowDecorations { + /// True when the app owns the titlebar and must draw controls and resize + /// edges. False means the platform still draws a real titlebar and the + /// frontend must render nothing. + pub client_side: bool, + /// The desktop's button layout, e.g. `":minimize,maximize,close"`. Only + /// meaningful when `client_side` is true. + pub layout: Option, +} + +/// Whether to drop server-side decorations on this Linux session. +/// +/// Enabled everywhere except KDE Plasma on Wayland, and overridable with +/// `DONUT_LINUX_CLIENT_DECORATIONS=1|0`. +/// +/// The KDE/Wayland exclusion is deliberate and is about a failure mode, not a +/// preference. GTK3 speaks no `xdg-decoration`; when a window is built +/// undecorated, GTK does not mark it client-decorated, and on Wayland it +/// therefore *announces server-side decorations* to the compositor. mutter +/// ignores that (it never decorates Wayland toplevels), which is why GNOME +/// works. KWin honors it, so Plasma would be free to draw a Breeze titlebar +/// directly above the one the app draws — two titlebars, worse than the +/// feature is good. Whether it actually does depends on the decoration mode +/// KWin advertises, which could not be established from documentation and +/// cannot be tested from here, so this stays off until somebody can run it. +/// +/// KDE on X11 is *not* excluded: there the request travels as `_MOTIF_WM_HINTS`, +/// which KWin has honored for as long as it has existed. +#[cfg(target_os = "linux")] +pub fn use_client_side_decorations() -> bool { + if let Ok(value) = std::env::var("DONUT_LINUX_CLIENT_DECORATIONS") { + let forced = matches!(value.trim(), "1" | "true" | "yes"); + log::info!("Client-side decorations forced to {forced} by DONUT_LINUX_CLIENT_DECORATIONS"); + return forced; + } + + let env = |key: &str| std::env::var(key).unwrap_or_default().to_lowercase(); + + // GDK_BACKEND is a comma-separated preference list ("wayland,x11"), and GDK + // takes the FIRST entry it can open. Testing for a substring would read + // "wayland,x11" as X11 and hand a Plasma Wayland session the undecorated + // path this guard exists to withhold. + let backend = env("GDK_BACKEND"); + let preferred = backend + .split(',') + .map(str::trim) + .find(|value| !value.is_empty()); + let on_wayland = match preferred { + Some("x11") => false, + Some("wayland") => true, + // Unset or something exotic: fall back to what the session advertises. + _ => { + !std::env::var("WAYLAND_DISPLAY") + .unwrap_or_default() + .is_empty() + && env("XDG_SESSION_TYPE") != "x11" + } + }; + + let on_kde = env("XDG_CURRENT_DESKTOP").contains("kde") + || env("XDG_SESSION_DESKTOP").contains("plasma") + || env("DESKTOP_SESSION").contains("plasma") + || !std::env::var("KDE_FULL_SESSION") + .unwrap_or_default() + .is_empty(); + + if on_kde && on_wayland { + log::info!( + "Keeping server-side decorations: KWin on Wayland may draw its own titlebar over the \ + app's. Set DONUT_LINUX_CLIENT_DECORATIONS=1 to override." + ); + return false; + } + true +} + +#[cfg(target_os = "linux")] +mod imp { + use std::sync::Mutex; + + lazy_static::lazy_static! { + static ref LAYOUT: Mutex> = Mutex::new(None); + } + + fn store(layout: Option) { + if let Ok(mut slot) = LAYOUT.lock() { + *slot = layout; + } + } + + pub fn cached() -> Option { + LAYOUT.lock().ok().and_then(|slot| slot.clone()) + } + + /// Read the layout and subscribe to changes. + /// + /// MUST be called on the GTK main thread — `gtk::Settings::default()` panics + /// elsewhere, and the notify subscription has to be attached from the thread + /// owning the GTK main context. The app's `setup` hook already runs there. + pub fn init(app: &tauri::AppHandle) { + use gtk::prelude::*; + use tauri::Emitter; + + let Some(settings) = gtk::Settings::default() else { + log::warn!("No GTK settings available; using the default decoration layout"); + return; + }; + + store(settings.gtk_decoration_layout().map(|v| v.to_string())); + log::info!( + "Window decoration layout: {}", + cached().as_deref().unwrap_or("") + ); + + // The user can rearrange titlebar buttons while the app is running, and + // every other application follows immediately. Note this never fires where + // the value comes only from gtk-3.0/settings.ini (a KDE X11 box with no + // xsettings daemon) — there it is simply static until restart. + let handle = app.clone(); + settings.connect_gtk_decoration_layout_notify(move |settings| { + let layout = settings.gtk_decoration_layout().map(|v| v.to_string()); + log::info!( + "Window decoration layout changed to: {}", + layout.as_deref().unwrap_or("") + ); + store(layout.clone()); + if let Err(e) = handle.emit("window-decoration-layout-changed", layout) { + log::warn!("Failed to emit window decoration layout change: {e}"); + } + }); + } +} + +#[cfg(not(target_os = "linux"))] +mod imp { + pub fn init(_app: &tauri::AppHandle) {} +} + +pub use imp::init; + +/// How this window is decorated, and the desktop's button layout when the app +/// owns the titlebar. +#[tauri::command] +pub fn get_window_decoration_layout() -> WindowDecorations { + #[cfg(target_os = "linux")] + { + let client_side = use_client_side_decorations(); + WindowDecorations { + client_side, + layout: if client_side { imp::cached() } else { None }, + } + } + // Every other platform keeps a real titlebar: macOS makes the native one + // transparent, Windows draws its own controls on a fixed layout. + #[cfg(not(target_os = "linux"))] + { + WindowDecorations { + client_side: false, + layout: None, + } + } +} diff --git a/src-tauri/src/xray/error.rs b/src-tauri/src/xray/error.rs index b98b910..bad19d7 100644 --- a/src-tauri/src/xray/error.rs +++ b/src-tauri/src/xray/error.rs @@ -27,3 +27,42 @@ pub enum XrayError { #[error("failed to serialize Xray client configuration")] Serialization, } + +impl XrayError { + /// A stable, translatable identifier for *why* a URI was rejected. + /// + /// Donut supports one VLESS shape — REALITY + XTLS Vision over TCP — so most + /// rejections are "your setup is a kind we do not support", not "you made a + /// typo". The frontend turns these into a sentence naming the unsupported + /// part; without them every rejection reads as a malformed URI and a user + /// with a working WebSocket or plain-TLS server has no idea why it failed. + pub fn reason_code(&self) -> &'static str { + match self { + Self::UnsupportedScheme => "scheme", + Self::UnsupportedValue { field, .. } | Self::InvalidField { field, .. } => match *field { + "security" => "security", + "flow" => "flow", + "type" => "transport", + "encryption" => "encryption", + "headerType" => "headerType", + "fp" => "fingerprint", + // A malformed sni/public key is the same user-facing problem as a + // missing one, so it earns the same specific help rather than the + // generic "invalid URI". + "sni" | "server_name" => "sni", + "pbk" | "public_key" => "publicKey", + _ => "malformed", + }, + Self::MissingField(field) => match *field { + "sni" => "sni", + "pbk" => "publicKey", + "security" => "security", + "flow" => "flow", + _ => "malformed", + }, + Self::UnsupportedParameter(_) => "parameter", + Self::DuplicateParameter(_) => "malformed", + Self::InvalidUri | Self::Serialization => "malformed", + } + } +} diff --git a/src-tauri/src/xray/uri.rs b/src-tauri/src/xray/uri.rs index e86841e..ea7e290 100644 --- a/src-tauri/src/xray/uri.rs +++ b/src-tauri/src/xray/uri.rs @@ -61,10 +61,10 @@ pub fn parse_vless_uri(input: &str) -> XrayResult { }; let port = url.port().ok_or(XrayError::MissingField("port"))?; - let parameters = parse_parameters(&url)?; - require_value(¶meters, "security", "reality")?; - require_value(¶meters, "flow", VlessFlow::Vision.as_str())?; - optional_value(¶meters, "encryption", "none")?; + let (parameters, unsupported) = parse_parameters(&url)?; + + // Transport first: it is the most common reason a real-world VLESS server is + // unusable here, and it explains the stray parameters that come with it. match parameters.get("type").map(String::as_str) { None | Some("tcp" | "raw") => {} Some(_) => { @@ -74,8 +74,17 @@ pub fn parse_vless_uri(input: &str) -> XrayResult { }); } } + require_value(¶meters, "security", "reality")?; + require_value(¶meters, "flow", VlessFlow::Vision.as_str())?; + optional_value(¶meters, "encryption", "none")?; optional_value(¶meters, "headerType", "none")?; + // Only once the shape is known-good does an unrecognized parameter become + // the most useful thing to report. + if let Some(name) = unsupported.into_iter().next() { + return Err(XrayError::UnsupportedParameter(name)); + } + let server_name = required_parameter(¶meters, "sni")?.to_string(); let public_key = required_parameter(¶meters, "pbk")?.to_string(); let short_id = parameters.get("sid").cloned().unwrap_or_default(); @@ -162,15 +171,28 @@ pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> Xray query.append_pair("type", "tcp"); query.append_pair("headerType", "none"); } - url.set_fragment(name); + // The parser percent-DECODES the fragment, so the exporter must encode it or + // a name containing `%` (or `#`) comes back different every time the URI is + // canonicalized — the name mutates a little more on each save. + let encoded_name = name.map(|value| urlencoding::encode(value).into_owned()); + url.set_fragment(encoded_name.as_deref()); Ok(url.into()) } -fn parse_parameters(url: &Url) -> XrayResult> { +/// Split the query into recognized parameters and the names of the rest. +/// +/// Unrecognized names are returned rather than rejected on the spot so the +/// caller can report the *shape* problem first. A WebSocket URI always carries +/// `path` (and usually `host`), gRPC carries `serviceName` — naming those keys +/// instead of the transport sends the user deleting parameters when the real +/// answer is that Donut only speaks plain TCP. +fn parse_parameters(url: &Url) -> XrayResult<(HashMap, Vec)> { let mut parameters = HashMap::new(); + let mut unsupported = Vec::new(); for (name, value) in url.query_pairs() { if !SUPPORTED_PARAMETERS.contains(&name.as_ref()) { - return Err(XrayError::UnsupportedParameter(name.into_owned())); + unsupported.push(name.into_owned()); + continue; } if parameters .insert(name.to_string(), value.into_owned()) @@ -179,7 +201,7 @@ fn parse_parameters(url: &Url) -> XrayResult> { return Err(XrayError::DuplicateParameter(name.into_owned())); } } - Ok(parameters) + Ok((parameters, unsupported)) } fn required_parameter<'a>( @@ -230,6 +252,109 @@ mod tests { const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4"; + /// Donut accepts exactly one VLESS shape, so most rejections mean "your + /// server is a kind we do not support" rather than "you mistyped". These pin + /// the reason each rejection reports, because the UI turns it into the one + /// sentence that tells a user with a working WebSocket or plain-TLS server + /// why Donut will not take it. + #[test] + fn unsupported_setups_report_which_part_is_unsupported() { + let good = format!( + "vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\ +&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome" + ); + assert!(parse_vless_uri(&good).is_ok(), "baseline URI must parse"); + + let reason = |uri: &str| parse_vless_uri(uri).unwrap_err().reason_code(); + + // Plain TLS instead of REALITY — the most common real-world setup. + assert_eq!( + reason(&good.replace("security=reality", "security=tls")), + "security" + ); + assert_eq!( + reason(&good.replace("flow=xtls-rprx-vision", "flow=none")), + "flow" + ); + // WebSocket / gRPC transports. + assert_eq!(reason(&good.replace("type=tcp", "type=ws")), "transport"); + assert_eq!(reason(&good.replace("type=tcp", "type=grpc")), "transport"); + assert_eq!(reason(&good.replace("&sni=a.com", "")), "sni"); + assert_eq!( + reason(&good.replace("&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4", "")), + "publicKey" + ); + assert_eq!(reason(&good.replace("vless://", "vmess://")), "scheme"); + assert_eq!(reason("not a uri"), "malformed"); + } + + /// The URIs users actually paste, not canonical-REALITY-with-one-field-changed. + /// + /// A real WebSocket link carries `path` (and usually `host`); a gRPC link + /// carries `serviceName`. Those keys are not in SUPPORTED_PARAMETERS, so + /// before the shape was checked first they produced "unsupported option" + /// and sent the user deleting query parameters instead of telling them + /// Donut only speaks plain TCP. + #[test] + fn a_display_name_survives_an_export_parse_round_trip() { + // Percent signs are legal in a fragment, so they used to pass through + // unencoded and then get decoded on the way back in — "50% off" became + // "50 off"-ish and drifted further on every canonicalizing save. + for name in ["50% off", "a#b", "spaced name", "100%25", "üñî"] { + let parsed = parse_vless_uri(&format!( + "vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\ +&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4" + )) + .expect("baseline parses"); + + let exported = export_vless_uri(&parsed.config, Some(name)).expect("exports"); + let reparsed = parse_vless_uri(&exported).expect("re-parses"); + assert_eq!( + reparsed.name.as_deref(), + Some(name), + "display name mutated across a round trip: {exported}" + ); + + // And a second round trip must be a fixed point, not drift again. + let exported_again = + export_vless_uri(&reparsed.config, reparsed.name.as_deref()).expect("re-exports"); + assert_eq!(exported, exported_again); + } + } + + #[test] + fn real_world_websocket_and_grpc_links_name_the_transport() { + let ws = format!( + "vless://{ID}@cdn.example.com:443?encryption=none&security=tls&type=ws\ +&path=%2Fray&host=cdn.example.com&sni=cdn.example.com#WS%20node" + ); + assert_eq!( + parse_vless_uri(&ws).unwrap_err().reason_code(), + "transport", + "a WebSocket link must be told its transport is unsupported" + ); + + let grpc = format!( + "vless://{ID}@grpc.example.com:443?encryption=none&security=reality&type=grpc\ +&serviceName=gun&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4" + ); + assert_eq!( + parse_vless_uri(&grpc).unwrap_err().reason_code(), + "transport" + ); + + // A genuinely unknown option on an otherwise-supported URI still reports + // as a parameter problem, which is the accurate answer there. + let odd = format!( + "vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\ +&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&madeUpKey=1" + ); + assert_eq!( + parse_vless_uri(&odd).unwrap_err().reason_code(), + "parameter" + ); + } + fn public_key() -> String { URL_SAFE_NO_PAD.encode([7_u8; 32]) } diff --git a/src-tauri/src/xray_worker_runner.rs b/src-tauri/src/xray_worker_runner.rs index bc7d944..692cd38 100644 --- a/src-tauri/src/xray_worker_runner.rs +++ b/src-tauri/src/xray_worker_runner.rs @@ -165,7 +165,7 @@ pub async fn start_xray_worker( ) -> Result> { let _start_guard = XRAY_START_LOCK.lock().await; parse_vless_uri(vless_uri) - .map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?; + .map_err(|error| -> Box { crate::vless_config_error(&error).into() })?; crate::proxy_runner::ensure_sidecar_version().await?; ensure_xray_binary()?; let owner_pid = std::process::id(); @@ -521,14 +521,14 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box Box { crate::vless_config_error(&error).into() })?; let runtime = XrayClientRuntime { listen_port: config.local_port, username: config.username.clone(), password: config.password.clone(), }; let runtime_json = build_client_config_json(&parsed.config, &runtime) - .map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?; + .map_err(|error| -> Box { crate::vless_config_error(&error).into() })?; write_xray_runtime_config(&config.id, runtime_json.as_bytes()) .map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?; let runtime_path = crate::xray_worker_storage::xray_runtime_config_path(&config.id); diff --git a/src-tauri/src/xray_worker_storage.rs b/src-tauri/src/xray_worker_storage.rs index a3c7248..17d3355 100644 --- a/src-tauri/src/xray_worker_storage.rs +++ b/src-tauri/src/xray_worker_storage.rs @@ -182,6 +182,39 @@ fn worker_is_tombstoned(id: &str) -> bool { xray_worker_tombstone_path(id).exists() } +/// How long a tombstone has to outlive its worker. +/// +/// It only has to survive long enough to beat a write already in flight from +/// the process that owned that id. A day is many orders of magnitude more than +/// that, and bounds a directory that otherwise gains a file per worker forever. +const TOMBSTONE_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); + +/// Drop tombstones old enough that nothing could still be racing them. +fn prune_stale_tombstones() { + let Ok(entries) = fs::read_dir(crate::proxy_storage::get_storage_dir()) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("stopped") { + continue; + } + let aged_out = path + .metadata() + .and_then(|meta| meta.modified()) + .map(|modified| { + modified + .elapsed() + .map(|age| age > TOMBSTONE_TTL) + .unwrap_or(false) + }) + .unwrap_or(false); + if aged_out { + let _ = fs::remove_file(&path); + } + } +} + pub fn create_xray_worker_log(id: &str) -> std::io::Result { ensure_private_storage_dir()?; if worker_is_tombstoned(id) { @@ -275,6 +308,8 @@ pub fn delete_xray_worker_config(id: &str) -> bool { } pub fn list_xray_worker_configs() -> Vec { + // Cheap, and this is the one call every sweep already makes. + prune_stale_tombstones(); let storage_dir = crate::proxy_storage::get_storage_dir(); let Ok(entries) = fs::read_dir(storage_dir) else { return Vec::new(); diff --git a/src/app/page.tsx b/src/app/page.tsx index 37664d3..f16f5c5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -314,7 +314,12 @@ export default function Home() { } }, [cloudUser]); - const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured; + // Cloud sync follows `cloudBackup`, NOT `crossOsFingerprints`. They agreed on + // every plan until Solo, which buys 20 cloud backups and deliberately has no + // fingerprint editing — so deriving sync from the fingerprint capability put a + // Pro badge on the one feature a Solo customer is paying for. + const cloudBackupUnlocked = getEntitlements(cloudUser).cloudBackup; + const syncUnlocked = cloudBackupUnlocked || selfHostedSyncConfigured; const [currentPage, setCurrentPage] = useState("profiles"); const [accountDialogOpen, setAccountDialogOpen] = useState(false); diff --git a/src/components/cookie-bot-enrol-dialog.tsx b/src/components/cookie-bot-enrol-dialog.tsx index 5ad249b..beb8d1d 100644 --- a/src/components/cookie-bot-enrol-dialog.tsx +++ b/src/components/cookie-bot-enrol-dialog.tsx @@ -2,14 +2,20 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LuChevronRight, LuInfo } from "react-icons/lu"; import { - CADENCES, - type CadenceId, + LuChevronRight, + LuInfo, + LuPencil, + LuPlus, + LuTrash2, + LuX, +} from "react-icons/lu"; +import { cadenceForMask, clockToMinutes, + DAYS_NIGHTLY, enableProfileSync, formatHours, minutesToClock, @@ -21,6 +27,10 @@ import { profileTimezone, RemoteHoursMeter, resolvedOs, + scheduleSlots, + templateErrorMessage, + weekdayNames, + weeklyRuns, } from "@/components/cookie-bot-shared"; import { AnimatedTabs, @@ -55,9 +65,17 @@ import { type CookieBotPresetList, type CookieBotSchedule, type CookieBotScheduleInput, + type CookieBotSlot, + type CookieBotTemplate, + type CookieBotUserTemplate, checkCookieBotConflicts, + createCookieBotUserTemplate, + deleteCookieBotUserTemplate, getCookieBotPresets, + getCookieBotUserTemplates, + isUserTemplateId, saveCookieBotSchedule, + updateCookieBotUserTemplate, } from "@/lib/cookie-bot"; import { SCHEDULE_BOUNDS } from "@/lib/cookie-bot-limits"; import { canUseCookieBot } from "@/lib/entitlements"; @@ -84,6 +102,15 @@ const { maxSites: MAX_SITES, } = SCHEDULE_BOUNDS; +/** + * The two bounds the server publishes in `presets().limits` and nothing else + * mirrors. Used only when a deployment predates that object: reading a missing + * bound as 0 would leave "Add a time" permanently disabled and refuse every + * name the user could type. + */ +const FALLBACK_MAX_SLOTS = 14; +const FALLBACK_MAX_TEMPLATE_NAME = 80; + /** The default start: deep enough into the night to be plausible anywhere. */ const DEFAULT_RUN_AT_MINUTE = 2 * 60; @@ -93,6 +120,47 @@ const PRESET_LABEL_KEYS: Record = { deep: "cookieBot.preset.deep", }; +/** + * Local names for the curated templates this build knows, on the same contract + * as the presets above: the server ships English so a template added after this + * release still renders, and a recognised id uses the translated label instead. + */ +const TEMPLATE_LABEL_KEYS: Record = { + "low-intent-purchaser": { + name: "cookieBot.template.lowIntentPurchaser", + hint: "cookieBot.template.lowIntentPurchaserHint", + }, +}; + +/** Where this enrolment's sites come from. */ +type SiteSource = "own" | "template" | "saved"; + +/** + * One row of the calendar, as the form holds it. + * + * The time stays a string because that is what a `` owns: a + * half-typed "0" is a state the user passes through, and round-tripping it + * through minutes would rewrite the field under the cursor. + */ +interface SlotDraft { + /** Stable identity, so removing a row does not re-key the ones after it. */ + key: string; + daysMask: number; + runAt: string; +} + +let slotKeySeq = 0; + +function makeSlot(daysMask: number, runAt: string): SlotDraft { + slotKeySeq += 1; + return { key: `slot-${slotKeySeq}`, daysMask, runAt }; +} + +/** What a profile that has never been enrolled starts on: every night, 02:00. */ +function defaultSlot(): SlotDraft { + return makeSlot(DAYS_NIGHTLY, minutesToClock(DEFAULT_RUN_AT_MINUTE)); +} + interface EnrolTarget { profile: BrowserProfile; check: PreflightResult; @@ -147,10 +215,14 @@ export function CookieBotEnrolDialog({ const [presets, setPresets] = useState(null); const [isLoadingPresets, setIsLoadingPresets] = useState(false); const presetList = useMemo(() => presets?.presets ?? [], [presets]); + const templateList = useMemo(() => presets?.templates ?? [], [presets]); const defaultPreset = useMemo(() => pickDefaultPreset(presets), [presets]); + const maxSlots = presets?.limits?.max_slots ?? FALLBACK_MAX_SLOTS; + const maxNameLength = + presets?.limits?.max_template_name_length ?? FALLBACK_MAX_TEMPLATE_NAME; /** - * Read the server's catalogue of intensities. + * Read the server's catalogue of intensities and curated templates. * * An imperative loader rather than an effect keyed off an attempt counter, * because the enrolment cannot name a preset without this: a transient @@ -171,18 +243,45 @@ export function CookieBotEnrolDialog({ } }, []); + const [userTemplates, setUserTemplates] = useState< + CookieBotUserTemplate[] | null + >(null); + const [templatesFailed, setTemplatesFailed] = useState(false); + const [isLoadingTemplates, setIsLoadingTemplates] = useState(false); + + /** + * The user's own saved lists. + * + * Kept apart from the preset load rather than folded into it: these are the + * only thing on this screen a user can create, so a failure here has to stay + * visible and retryable next to the list itself, while a preset failure + * blocks the save outright. + */ + const loadUserTemplates = useCallback(async () => { + setIsLoadingTemplates(true); + try { + setUserTemplates(await getCookieBotUserTemplates()); + setTemplatesFailed(false); + } catch { + setUserTemplates(null); + setTemplatesFailed(true); + } finally { + setIsLoadingTemplates(false); + } + }, []); + useEffect(() => { if (!isOpen) return; void loadPresets(); - }, [isOpen, loadPresets]); + void loadUserTemplates(); + }, [isOpen, loadPresets, loadUserTemplates]); const [preset, setPreset] = useState(""); - const [runAt, setRunAt] = useState( - minutesToClock(DEFAULT_RUN_AT_MINUTE), - ); - const [daysMask, setDaysMask] = useState(CADENCES[0].mask); + const [slots, setSlots] = useState(() => [defaultSlot()]); const [maxMinutes, setMaxMinutes] = useState(FALLBACK_MAX_MINUTES); const [maxMinutesTouched, setMaxMinutesTouched] = useState(false); + const [source, setSource] = useState("own"); + const [templateId, setTemplateId] = useState(""); const [sitesText, setSitesText] = useState(""); const [adjustOpen, setAdjustOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -198,14 +297,29 @@ export function CookieBotEnrolDialog({ useEffect(() => { if (!isOpen) return; setPreset(existing?.preset ?? defaultPreset?.id ?? ""); - setRunAt(minutesToClock(existing?.run_at_minute ?? DEFAULT_RUN_AT_MINUTE)); - setDaysMask(existing?.days_mask ?? CADENCES[0].mask); + // `scheduleSlots` and not `existing.slots`: a server that predates + // multi-slot sends only the mirrored pair, and reading the field directly + // would open the editor on an empty calendar and then save that emptiness + // over a schedule that was firing perfectly well. + const restored = (existing ? scheduleSlots(existing) : []).map((slot) => + makeSlot(slot.days_mask, minutesToClock(slot.run_at_minute)), + ); + setSlots(restored.length > 0 ? restored : [defaultSlot()]); setMaxMinutes( existing?.max_minutes ?? defaultPreset?.typical_minutes ?? FALLBACK_MAX_MINUTES, ); setMaxMinutesTouched(Boolean(existing)); + const storedTemplate = existing?.template_id ?? ""; + setTemplateId(storedTemplate); + setSource( + storedTemplate + ? isUserTemplateId(storedTemplate) + ? "saved" + : "template" + : "own", + ); setSitesText((existing?.sites ?? []).join("\n")); setAdjustOpen(false); setConflict(null); @@ -233,25 +347,88 @@ export function CookieBotEnrolDialog({ [targets], ); - const runAtMinute = clockToMinutes(runAt); + /** The calendar as the wire spells it, with unreadable times dropped. */ + const wireSlots = useMemo(() => { + const out: CookieBotSlot[] = []; + for (const slot of slots) { + const minute = clockToMinutes(slot.runAt); + if (minute === null || slot.daysMask === 0) continue; + out.push({ days_mask: slot.daysMask, run_at_minute: minute }); + } + return out; + }, [slots]); + + // A row the user is still typing into is not a row that can be saved, and it + // is not an error either — the confirm button names the missing piece. + const slotsIncomplete = wireSlots.length !== slots.length; + const firstMinute = wireSlots[0]?.run_at_minute ?? DEFAULT_RUN_AT_MINUTE; + + /** + * Rows that repeat a (days, time) another row already claims. + * + * The server de-duplicates a slot list before it stores it, so submitting two + * identical rows silently stores one: the calendar comes back from the next + * open a row shorter, at a different length than the user left it, with + * nothing having said which row went or why. Refused here instead — the row + * is marked and the confirm button names the reason, the same contract the + * empty-weekday case already follows. + * + * Only an EXACT repeat counts. Two rows sharing one weekday at one minute + * also collapse to a single run, but they still describe nights the other + * does not, so the hours estimate accounts for them (see `weeklyRuns`) rather + * than the form refusing them. + */ + const duplicateSlotKeys = useMemo(() => { + const claimed = new Set(); + const repeats = new Set(); + for (const slot of slots) { + const minute = clockToMinutes(slot.runAt); + if (minute === null || slot.daysMask === 0) continue; + const packed = minute * 128 + slot.daysMask; + if (claimed.has(packed)) repeats.add(slot.key); + else claimed.add(packed); + } + return repeats; + }, [slots]); + const hasDuplicateSlot = duplicateSlotKeys.size > 0; + const sites = useMemo(() => normaliseSites(sitesText), [sitesText]); - const sitesTooMany = sites.length > MAX_SITES; + const usingTemplate = source !== "own"; + const chosenSaved = useMemo( + () => userTemplates?.find((item) => item.id === templateId) ?? null, + [userTemplates, templateId], + ); + // A saved list this enrolment names but the account no longer has — deleted + // from another device, most likely. Saving would 404, so it is said here + // rather than after the round trip. + const savedMissing = + source === "saved" && + templateId.length > 0 && + userTemplates !== null && + chosenSaved === null; + + const sitesTooMany = !usingTemplate && sites.length > MAX_SITES; // v1 browses the user's declared sites and nothing else, so an empty list is // not a schedule the server can accept — it 400s with COOKIE_BOT_SITE_LIMIT // and, before this, `canSubmit` did not ask. The three-click happy path // (bot cell -> Enrol -> "Enrol tonight") posted `sites: []` and failed every // single time, with the only input the bot cannot run without hidden inside a - // collapsed disclosure. - const sitesTooFew = sites.length < MIN_SITES; + // collapsed disclosure. A template is the other way to answer the same + // question, so it satisfies this too. + const sitesTooFew = !usingTemplate && sites.length < MIN_SITES; + const templateMissing = usingTemplate && templateId.length === 0; const maxMinutesValid = Number.isFinite(maxMinutes) && maxMinutes >= MIN_MAX_MINUTES && maxMinutes <= MAX_MAX_MINUTES; const presetsUnavailable = presets === null; - // A week's machine time from the operator's own two numbers. The budget it is - // compared against is the server's; nothing here decides entitlement. - const weeklyHours = (nightsPerWeek(daysMask) * maxMinutes) / 60; + // A week's machine time from the operator's own numbers, summed across every + // slot: three times a night costs three times the hours, and this is the only + // place that shows it before the commit. The budget it is compared against is + // the server's; nothing here decides entitlement. + const runsPerWeek = weeklyRuns(wireSlots); + const weeklyHours = (runsPerWeek * maxMinutes) / 60; const remainingHours = quota?.remaining_hours ?? null; const overBudget = remainingHours !== null && weeklyHours > remainingHours && !isEdit; @@ -259,10 +436,15 @@ export function CookieBotEnrolDialog({ const canSubmit = eligible.length > 0 && preset.length > 0 && - runAtMinute !== null && + wireSlots.length > 0 && + !slotsIncomplete && + !hasDuplicateSlot && + wireSlots.length <= maxSlots && maxMinutesValid && !sitesTooMany && !sitesTooFew && + !templateMissing && + !savedMissing && !isSaving; // A single-profile enrolment asks the server up front whether a teammate @@ -289,22 +471,28 @@ export function CookieBotEnrolDialog({ const buildInput = useCallback( (profile: BrowserProfile): CookieBotScheduleInput | null => { - const minute = clockToMinutes(runAt); const platform = resolvedOs(profile); - if (minute === null || !platform) return null; + if (wireSlots.length === 0 || !platform) return null; return { profile_name: profile.name, platform: platform as CookieBotPlatform, enabled: true, - run_at_minute: minute, - days_mask: daysMask, + // The pair is the mirror of the first slot, sent for a server that + // predates multi-slot scheduling: it stores the first time rather than + // nothing at all. + run_at_minute: wireSlots[0].run_at_minute, + days_mask: wireSlots[0].days_mask, + slots: wireSlots, timezone: profileTimezone(profile), preset, + // A template stands IN FOR the site list. The server refuses a body + // carrying both, so the unused half is sent empty rather than stale. + template_id: usingTemplate ? templateId : undefined, max_minutes: Math.round(maxMinutes), - sites, + sites: usingTemplate ? [] : sites, }; }, - [runAt, daysMask, preset, maxMinutes, sites], + [wireSlots, preset, maxMinutes, sites, usingTemplate, templateId], ); const submit = useCallback( @@ -332,7 +520,7 @@ export function CookieBotEnrolDialog({ if (!conflictParams) { conflictParams = { email: parsed.params?.email ?? "", - time: parsed.params?.time ?? minutesToClock(runAtMinute ?? 0), + time: parsed.params?.time ?? minutesToClock(firstMinute), }; } continue; @@ -357,7 +545,10 @@ export function CookieBotEnrolDialog({ } if (firstError) { - showErrorToast(translateBackendError(t, firstError)); + // A refusal that names a saved list is translated as one: the shared + // table does not carry the template codes, and "Something went wrong: + // COOKIE_BOT_TEMPLATE_NOT_FOUND" tells nobody to pick another list. + showErrorToast(templateErrorMessage(t, firstError)); if (saved > 0) { void refreshCookieBot(); onSaved?.(); @@ -383,7 +574,7 @@ export function CookieBotEnrolDialog({ onSaved, onClose, t, - runAtMinute, + firstMinute, refreshCookieBot, ], ); @@ -424,13 +615,18 @@ export function CookieBotEnrolDialog({ ? t("cookieBot.enrol.titleOne", { name: single.name }) : t("cookieBot.enrol.titleCount", { count: profiles.length }); - const cadenceId = cadenceForMask(daysMask); // One complete sentence per cadence rather than a label spliced into a // fragment: "Runs Nightly at 02:00" only reads correctly in English, and a - // translator needs the whole clause to reorder. - const summaryKey = cadenceId - ? `cookieBot.enrol.summary${cadenceId[0].toUpperCase()}${cadenceId.slice(1)}` - : "cookieBot.enrol.summaryCustom"; + // translator needs the whole clause to reorder. A calendar with more than one + // start time has no single hour to name, so it gets its own sentence instead + // of the first slot's time standing in for all of them. + const oneSlot = wireSlots.length === 1; + const cadenceId = oneSlot ? cadenceForMask(wireSlots[0].days_mask) : null; + const summaryKey = !oneSlot + ? "cookieBot.enrol.summarySlots" + : cadenceId + ? `cookieBot.enrol.summary${cadenceId[0].toUpperCase()}${cadenceId.slice(1)}` + : "cookieBot.enrol.summaryCustom"; return ( {t(summaryKey, { - count: nightsPerWeek(daysMask), - time: minutesToClock(runAtMinute ?? DEFAULT_RUN_AT_MINUTE), + count: oneSlot + ? nightsPerWeek(wireSlots[0].days_mask) + : runsPerWeek, + time: minutesToClock(firstMinute), minutes: Math.round(maxMinutes), })}

@@ -533,35 +731,106 @@ export function CookieBotEnrolDialog({ )} - {/* Sites is not an adjustment: v1 browses the user's declared list - and nothing else, so it is the one input without which there is no + {/* Sites is not an adjustment: v1 browses one declared list and + nothing else, so it is the one input without which there is no run. It sat inside the collapsed "Adjust schedule" disclosure, which made the default path a form the server always refused. */} -