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
@@ -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. */}
-
+
+ {
+ setMaxMinutesTouched(true);
+ setMaxMinutes(Number(event.target.value));
+ }}
+ className="h-8 w-24 tabular-nums"
+ />
+
{presetList.length > 0 && (
@@ -791,6 +1077,11 @@ export function CookieBotEnrolDialog({
total: targets.length,
saving: isSaving,
needsSites: sitesTooFew,
+ needsTemplate: templateMissing || savedMissing,
+ needsSlot: slotsIncomplete || wireSlots.length === 0,
+ duplicateSlot: hasDuplicateSlot,
+ overSlotCap: wireSlots.length > maxSlots,
+ maxSlots,
needsPreset: preset.length === 0,
})}
@@ -813,6 +1104,555 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
);
}
+/* -------------------------------------------------------------------------- */
+/* Calendar */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * One weekday-set and one time.
+ *
+ * Days are a toggle strip rather than the three named cadences this control
+ * used to offer: those name a mask, and a calendar of several rows needs each
+ * row to be able to say something the three cannot. A single row with every day
+ * lit is still "every night", which is what the sentence at the top of the
+ * dialog goes on calling it.
+ */
+function SlotRow({
+ slot,
+ duplicate,
+ canRemove,
+ onChange,
+ onRemove,
+}: {
+ slot: SlotDraft;
+ /** This row repeats a (days, time) an earlier row already claims. */
+ duplicate: boolean;
+ canRemove: boolean;
+ onChange: (next: Partial) => void;
+ onRemove: () => void;
+}) {
+ const { t } = useTranslation();
+ const days = useMemo(() => weekdayNames(), []);
+ // A row with no day, or a half-typed time, is what the confirm button is
+ // refusing to save. Marking it here is what connects that refusal to the
+ // control the user has to touch — the button names the reason, this names
+ // the row.
+ const noDay = slot.daysMask === 0;
+ // A repeat is marked on the time rather than the days because changing the
+ // time is what resolves it without giving up a night the row was added for.
+ const badTime = clockToMinutes(slot.runAt) === null || duplicate;
+
+ return (
+
+ );
+}
+
+/**
+ * The curated templates.
+ *
+ * The addresses are deliberately not shown and the copy says why as the feature
+ * it is: the list is maintained server-side, and each profile draws its own
+ * sample from it so no two profiles browse the same set. A published list would
+ * be one anybody could match on, which is the weakness the sampling exists to
+ * avoid.
+ */
+function CuratedPanel({
+ templates,
+ selectedId,
+ onSelect,
+}: {
+ templates: CookieBotTemplate[];
+ selectedId: string;
+ onSelect: (id: string) => void;
+}) {
+ const { t } = useTranslation();
+
+ if (templates.length === 0) {
+ return (
+
+ );
+}
+
/**
* Why a proxy is not optional, at the point where the refusal happens. A run
* without one leaves the fleet's own datacenter address, and hours of traffic
@@ -849,6 +1689,17 @@ function confirmLabel(
total: number;
saving: boolean;
needsSites: boolean;
+ needsTemplate: boolean;
+ needsSlot: boolean;
+ /** Two rows claim the same days and time; the server would store one. */
+ duplicateSlot: boolean;
+ /**
+ * Only reachable by editing an enrolment written while the server allowed
+ * more start times than it does now. Rare, and still owed a reason: a mute
+ * button on a form the user did not fill in is the worst version of this.
+ */
+ overSlotCap: boolean;
+ maxSlots: number;
needsPreset: boolean;
},
): string {
@@ -856,6 +1707,11 @@ function confirmLabel(
if (state.eligible === 0 && !state.isEdit)
return t("cookieBot.enrol.fixFirst");
if (state.needsSites) return t("cookieBot.enrol.addSitesFirst");
+ if (state.needsTemplate) return t("cookieBot.enrol.pickListFirst");
+ if (state.needsSlot) return t("cookieBot.enrol.finishCalendarFirst");
+ if (state.duplicateSlot) return t("cookieBot.enrol.duplicateSlot");
+ if (state.overSlotCap)
+ return t("cookieBot.enrol.slotsFull", { max: state.maxSlots });
if (state.needsPreset) return t("cookieBot.enrol.presetsMissing");
if (state.isEdit) return t("common.buttons.save");
if (state.eligible < state.total) {
diff --git a/src/components/cookie-bot-shared.tsx b/src/components/cookie-bot-shared.tsx
index cd3c09a..b6874ae 100644
--- a/src/components/cookie-bot-shared.tsx
+++ b/src/components/cookie-bot-shared.tsx
@@ -11,9 +11,11 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import type {
CookieBotRun,
CookieBotSchedule,
+ CookieBotSlot,
RemoteHoursQuota,
} from "@/lib/cookie-bot";
import { MOTION_EASE_OUT } from "@/lib/motion";
@@ -66,6 +68,84 @@ export function nightsPerWeek(mask: number): number {
return count;
}
+/* -------------------------------------------------------------------------- */
+/* Slots */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Every time-of-day an enrolment fires, from whichever shape the server sent.
+ *
+ * ALWAYS at least one slot. A server that predates multi-slot scheduling sends
+ * only the mirrored `run_at_minute` / `days_mask` pair, and a renderer that read
+ * `slots` directly would show an enrolment as firing at no time at all. Reading
+ * the wire through here is what keeps that fallback in one place.
+ */
+export function scheduleSlots(schedule: {
+ slots?: CookieBotSlot[];
+ run_at_minute: number;
+ days_mask: number;
+}): CookieBotSlot[] {
+ const slots = schedule.slots ?? [];
+ if (slots.length > 0) return slots;
+ return [
+ { days_mask: schedule.days_mask, run_at_minute: schedule.run_at_minute },
+ ];
+}
+
+/**
+ * How many times a week a whole calendar fires.
+ *
+ * Counted across slots, not read off the first one: an enrolment with three
+ * slots costs three times the hours, and the budget estimate beside it is the
+ * only place a user sees that before committing.
+ *
+ * DISTINCT (weekday, minute) pairs rather than a sum of night counts, because
+ * two slots landing on the same weekday at the same minute are the same
+ * instant and the server dispatches them as ONE run — `upcomingSlotsMulti`
+ * collapses coincident fires. Summing quoted a Mon+Tue and a Tue+Wed slot at
+ * 02:00 as four nights when it is three, and that inflated figure is what the
+ * over-budget warning is compared against.
+ */
+export function weeklyRuns(
+ slots: { days_mask: number; run_at_minute: number }[],
+): number {
+ const fires = new Set();
+ for (const slot of slots) {
+ for (let bit = 0; bit < 7; bit += 1) {
+ if ((slot.days_mask & (1 << bit)) !== 0) {
+ fires.add(bit * 1440 + slot.run_at_minute);
+ }
+ }
+ }
+ return fires.size;
+}
+
+/**
+ * Monday-first weekday names, from the viewer's own locale.
+ *
+ * Derived rather than translated into ten locale files: `narrow` already gives
+ * each language its own single-letter convention, and a hand-written table
+ * would be ten more places for Monday-first to be got wrong. The reference week
+ * is formatted in UTC so a user east of Greenwich does not see it shift by a
+ * day.
+ */
+export function weekdayNames(): { narrow: string; long: string }[] {
+ // 2024-01-01 was a Monday, which is bit 0 of the server's mask.
+ const monday = Date.UTC(2024, 0, 1);
+ const narrow = new Intl.DateTimeFormat(undefined, {
+ weekday: "narrow",
+ timeZone: "UTC",
+ });
+ const long = new Intl.DateTimeFormat(undefined, {
+ weekday: "long",
+ timeZone: "UTC",
+ });
+ return Array.from({ length: 7 }, (_, index) => {
+ const day = new Date(monday + index * 24 * 60 * 60 * 1000);
+ return { narrow: narrow.format(day), long: long.format(day) };
+ });
+}
+
/** A human cadence label. Unknown masks fall back to the night count. */
export function describeCadence(t: TFunction, mask: number): string {
const id = cadenceForMask(mask);
@@ -409,6 +489,36 @@ export function outcomeLabel(
return key ? t(key) : t("cookieBot.outcome.unknown", { code });
}
+/**
+ * The three refusals only the saved-list routes can raise.
+ *
+ * They are absent from the shared `backendErrors` table, so
+ * `translateBackendError` renders them through its unknown-code fallback: a
+ * user who reuses a name would be told "Something went wrong:
+ * COOKIE_BOT_TEMPLATE_NAME_TAKEN" instead of that the name is taken, in the one
+ * dialog where the fix is a single keystroke. Everything else — a signed-out
+ * desktop, an unreachable cloud — still goes through the shared translator.
+ */
+const TEMPLATE_ERROR_KEYS: Record = {
+ COOKIE_BOT_TEMPLATE_NAME_TAKEN: "cookieBot.enrol.templateNameTaken",
+ COOKIE_BOT_INVALID_TEMPLATE_NAME: "cookieBot.enrol.templateNameInvalid",
+ COOKIE_BOT_TEMPLATE_NOT_FOUND: "cookieBot.enrol.templateMissing",
+};
+
+export function templateErrorMessage(t: TFunction, error: unknown): string {
+ const parsed = parseBackendError(error);
+ const key = parsed ? TEMPLATE_ERROR_KEYS[parsed.code] : undefined;
+ if (!key) return translateBackendError(t, error);
+ const max = parsed?.params?.max;
+ // The server does not always send a limit. Interpolating an empty string
+ // rendered "a name of characters or fewer", so fall back to wording that
+ // does not need the number.
+ if (key === "cookieBot.enrol.templateNameInvalid" && !max) {
+ return t("cookieBot.enrol.templateNameInvalidNoMax");
+ }
+ return t(key, { max });
+}
+
/**
* The session state machine, named honestly. `provisioning -> ready -> live ->
* closed`, with `error` reachable from any of the first three, is what the
diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx
index d657401..3c4a533 100644
--- a/src/components/home-header.tsx
+++ b/src/components/home-header.tsx
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
+import { useWindowDecorations } from "@/hooks/use-window-decorations";
import { getCurrentOS } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type { GroupWithCount } from "@/types";
@@ -59,8 +60,14 @@ const HomeHeader = ({
}, []);
const isMacOS = platform === "macos";
+ const isLinux = platform === "linux";
const showProfileToolbar = !pageTitle;
+ // Same hook the controls use, so the reserved space can never disagree with
+ // what is actually drawn.
+ const decorations = useWindowDecorations();
+ const linuxLayout = decorations.clientSide ? decorations.layout : null;
+
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
const holdTimeoutRef = useRef(null);
@@ -179,14 +186,29 @@ const HomeHeader = ({
onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick}
className={cn(
- "flex h-11 items-center gap-2 border-b border-border bg-card pl-3 select-none",
+ "flex h-11 items-center gap-2 border-b border-border bg-card select-none",
// Windows: WindowDragArea renders three 44px native-style controls
// (minimize + maximize/restore + close) fixed at top-right with
// z-50, total 132px wide. Reserve 144px on the right edge so the
// "+ New" button and search input clear them with a few pixels of
// breathing room and never sit underneath the controls.
- isWindows ? "pr-[144px]" : "pr-3",
+ isWindows ? "pl-3 pr-[144px]" : null,
+ // Linux reserves its space through the inline style below, because the
+ // desktop chooses which side the controls sit on and how many there
+ // are. Everything else keeps the plain symmetric padding.
+ !isWindows && !isLinux ? "pl-3 pr-3" : null,
)}
+ style={
+ isLinux
+ ? {
+ // Each control is 44px wide; add the usual 12px gutter. Before
+ // the layout resolves, fall back to the gutter alone rather than
+ // to no padding, which would visibly shift the content.
+ paddingLeft: (linuxLayout?.left.length ?? 0) * 44 + 12,
+ paddingRight: (linuxLayout?.right.length ?? 0) * 44 + 12,
+ }
+ : undefined
+ }
>
{isMacOS && (
(DEFAULT_FORM);
+ // The local parse only covers scheme/host/port. Whether Donut can actually
+ // use the server — REALITY, XTLS Vision, plain TCP — is decided by the Rust
+ // parser, so ask it (below) and show the specific reason while the user is
+ // still editing rather than after they save. Declared here because
+ // `handleSubmit` guards on it.
+ const [vlessUnsupported, setVlessUnsupported] = useState(null);
const resetForm = useCallback(() => {
setForm(DEFAULT_FORM);
@@ -134,6 +140,11 @@ export function ProxyFormDialog({
return;
}
+ if (isVless && vlessUnsupported) {
+ toast.error(vlessUnsupported);
+ return;
+ }
+
if (!isVless && (!form.host.trim() || !form.port)) {
toast.error(t("proxies.form.hostPortRequired"));
return;
@@ -183,7 +194,7 @@ export function ProxyFormDialog({
} finally {
setIsSubmitting(false);
}
- }, [editingProxy, form, onClose, t]);
+ }, [editingProxy, form, onClose, t, vlessUnsupported]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
@@ -193,12 +204,37 @@ export function ProxyFormDialog({
const isVless = form.proxy_type === "vless";
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
+
+ const trimmedVlessUri = form.vless_uri.trim();
+ useEffect(() => {
+ if (!isVless || trimmedVlessUri.length === 0) {
+ setVlessUnsupported(null);
+ return;
+ }
+ let cancelled = false;
+ const timer = window.setTimeout(() => {
+ void invoke("validate_vless_uri", { uri: trimmedVlessUri })
+ .then(() => {
+ if (!cancelled) setVlessUnsupported(null);
+ })
+ .catch((error: unknown) => {
+ if (!cancelled) setVlessUnsupported(translateBackendError(t, error));
+ });
+ }, 300);
+ return () => {
+ cancelled = true;
+ window.clearTimeout(timer);
+ };
+ }, [isVless, trimmedVlessUri, t]);
+
const hasInvalidVlessUri =
- isVless && form.vless_uri.trim().length > 0 && !vlessEndpoint;
+ isVless &&
+ trimmedVlessUri.length > 0 &&
+ (!vlessEndpoint || vlessUnsupported !== null);
const isFormValid =
form.name.trim() &&
(isVless
- ? vlessEndpoint !== null
+ ? vlessEndpoint !== null && vlessUnsupported === null
: form.host.trim() &&
form.port > 0 &&
form.port <= 65535 &&
@@ -286,7 +322,7 @@ export function ProxyFormDialog({
role={hasInvalidVlessUri ? "alert" : undefined}
>
{hasInvalidVlessUri
- ? t("proxies.form.vlessUriInvalid")
+ ? (vlessUnsupported ?? t("proxies.form.vlessUriInvalid"))
: t("proxies.form.vlessUriHint")}
diff --git a/src/components/window-drag-area.tsx b/src/components/window-drag-area.tsx
index a0e1234..362af8c 100644
--- a/src/components/window-drag-area.tsx
+++ b/src/components/window-drag-area.tsx
@@ -3,19 +3,23 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
+import { useWindowDecorations } from "@/hooks/use-window-decorations";
import { getCurrentOS, type OperatingSystem } from "@/lib/platform";
+import type { WindowControl } from "@/lib/window-decorations";
+import { WindowResizeHandles } from "./window-resize-handles";
export function WindowDragArea() {
const { t } = useTranslation();
const [platform, setPlatform] = useState(null);
const [isMaximized, setIsMaximized] = useState(false);
+ const decorations = useWindowDecorations();
useEffect(() => {
setPlatform(getCurrentOS());
}, []);
useEffect(() => {
- if (platform !== "windows") return;
+ if (platform !== "windows" && platform !== "linux") return;
const win = getCurrentWindow();
let cancelled = false;
const sync = async () => {
@@ -38,42 +42,6 @@ export function WindowDragArea() {
};
}, [platform]);
- const handlePointerDown = (e: React.PointerEvent) => {
- if (e.button !== 0) return;
- e.preventDefault();
- e.stopPropagation();
-
- const startDrag = async () => {
- try {
- const window = getCurrentWindow();
- await window.startDragging();
- } catch (error) {
- console.error("Failed to start window dragging:", error);
- }
- };
-
- void startDrag();
- };
-
- // Linux: system decorations handle everything
- if (!platform || platform === "linux" || platform === "unknown") {
- return null;
- }
-
- // macOS: nothing to render here. The transparent native titlebar (set via
- // `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
- // handle dragging directly, and the sys-bar inside `home-header.tsx`
- // declares its own `data-tauri-drag-region` overlay for the WebView area.
- // The previous full-width fixed z-[999999] button was stealing every
- // click in the top 40px of the window.
- if (platform === "macos") {
- return null;
- }
-
- // Windows: minimize/maximize/close controls anchored at the top-right
- // corner of the sys-bar. The HomeHeader's own drag-region overlay handles window
- // dragging via Tauri 2, so we don't need a separate draggable spacer
- // covering the whole width.
const handleMinimize = async () => {
try {
await getCurrentWindow().minimize();
@@ -97,93 +65,172 @@ export function WindowDragArea() {
console.error("Failed to close window:", error);
}
};
- void handlePointerDown; // kept for backwards-compat; not used on Windows now
+ const renderControl = (control: WindowControl) => {
+ switch (control) {
+ case "minimize":
+ return (
+
+ );
+ case "maximize":
+ return (
+
+ );
+ case "close":
+ return (
+
+ );
+ }
+ };
+
+ if (!platform || platform === "unknown") {
+ return null;
+ }
+
+ // macOS: nothing to render here. The transparent native titlebar (set via
+ // `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
+ // handle dragging directly, and the sys-bar inside `home-header.tsx`
+ // declares its own `data-tauri-drag-region` overlay for the WebView area.
+ // The previous full-width fixed z-[999999] button was stealing every
+ // click in the top 40px of the window.
+ if (platform === "macos") {
+ return null;
+ }
+
+ // Linux: the window has no server-side decorations, so the app owns both the
+ // controls and the resize edges. Which buttons appear and on which side is a
+ // desktop-wide preference (GNOME's `button-layout`, KWin's decoration
+ // settings), read through GTK so both environments are honored.
+ if (platform === "linux") {
+ // Not resolved yet, or the session keeps server-side decorations (KDE on
+ // Wayland — see `use_client_side_decorations` in the backend). Either way
+ // there is a real titlebar and nothing for the app to draw.
+ if (!decorations.resolved || !decorations.clientSide) {
+ return null;
+ }
+ const { layout } = decorations;
+ return (
+ <>
+ {/* Dropping decorations also drops the compositor's drop shadow, and
+ neither Tauri nor tao exposes a Linux shadow API. Without some edge
+ the window is invisible against a similarly coloured desktop, so
+ draw a hairline. Not rounded: that needs a transparent window, which
+ would conflict with the WebView and the resize strips. */}
+ {!isMaximized && (
+
+ )}
+
+ {layout.left.length > 0 && (
+
+ {layout.left.map(renderControl)}
+
+ )}
+ {layout.right.length > 0 && (
+
+ {layout.right.map(renderControl)}
+
+ )}
+ >
+ );
+ }
+
+ // Windows: minimize/maximize/close controls anchored at the top-right
+ // corner of the sys-bar. The HomeHeader's own drag-region overlay handles
+ // window dragging via Tauri 2, so we don't need a separate draggable spacer
+ // covering the whole width.
return (
);
}
diff --git a/src/components/window-resize-handles.tsx b/src/components/window-resize-handles.tsx
new file mode 100644
index 0000000..c36d837
--- /dev/null
+++ b/src/components/window-resize-handles.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { getCurrentWindow } from "@tauri-apps/api/window";
+
+/**
+ * Mirrors the API's own `ResizeDirection`, which it declares but does not
+ * export. Structurally identical, so a drift would fail the call below.
+ */
+type ResizeDirection =
+ | "East"
+ | "North"
+ | "NorthEast"
+ | "NorthWest"
+ | "South"
+ | "SouthEast"
+ | "SouthWest"
+ | "West";
+
+/**
+ * Mouse resize areas for a window with no server-side decorations.
+ *
+ * `gtk_window_set_decorated(false)` removes GTK's own invisible resize border
+ * along with the frame, so without these the window can only be resized through
+ * window-manager shortcuts (Super+right-drag and friends). Each handle hands the
+ * pointer to the compositor via `begin_resize_drag`, which is the same call
+ * GTK's client-side decorations make, so edge snapping and the resize cursor
+ * come from the WM exactly as they do for a native window.
+ *
+ * Rendered only where the app owns the frame; on macOS the native titlebar is
+ * still in place and the system draws its own resize edges.
+ */
+
+/**
+ * GTK's own grab area is far wider, but all of it sits *outside* the window in
+ * the shadow margin. Ours is inside, so every pixel is taken from real content.
+ */
+/** Top edge only — it overlaps the 44px-tall window controls. */
+const TOP_EDGE = "6px";
+/** Sides and bottom overlap nothing, so they can be comfortably grabbable. */
+const EDGE = "8px";
+/** Corners need to win over the edges that overlap them. */
+const CORNER = "16px";
+
+interface Handle {
+ direction: ResizeDirection;
+ style: React.CSSProperties;
+ cursor: string;
+}
+
+const HANDLES: Handle[] = [
+ // Edges.
+ {
+ direction: "North",
+ cursor: "ns-resize",
+ style: { top: 0, left: CORNER, right: CORNER, height: TOP_EDGE },
+ },
+ {
+ direction: "South",
+ cursor: "ns-resize",
+ style: { bottom: 0, left: CORNER, right: CORNER, height: EDGE },
+ },
+ {
+ direction: "West",
+ cursor: "ew-resize",
+ style: { left: 0, top: CORNER, bottom: CORNER, width: EDGE },
+ },
+ {
+ direction: "East",
+ cursor: "ew-resize",
+ style: { right: 0, top: CORNER, bottom: CORNER, width: EDGE },
+ },
+ // Corners, drawn after the edges so they sit on top of the overlap.
+ {
+ direction: "NorthWest",
+ cursor: "nwse-resize",
+ style: { top: 0, left: 0, width: CORNER, height: TOP_EDGE },
+ },
+ {
+ direction: "NorthEast",
+ cursor: "nesw-resize",
+ style: { top: 0, right: 0, width: CORNER, height: TOP_EDGE },
+ },
+ {
+ direction: "SouthWest",
+ cursor: "nesw-resize",
+ style: { bottom: 0, left: 0, width: CORNER, height: CORNER },
+ },
+ {
+ direction: "SouthEast",
+ cursor: "nwse-resize",
+ style: { bottom: 0, right: 0, width: CORNER, height: CORNER },
+ },
+];
+
+export function WindowResizeHandles({ isMaximized }: { isMaximized: boolean }) {
+ // A maximized window has no resizable edge, and leaving the strips live would
+ // put invisible hit areas over real content. Tauri's own built-in undecorated
+ // resizing disables itself while maximized for the same reason.
+ if (isMaximized) {
+ return null;
+ }
+
+ const startResize =
+ (direction: ResizeDirection) => (e: React.PointerEvent) => {
+ // Left button only: right-click belongs to the WM/window menu, and a
+ // middle-click drag should not resize.
+ if (e.button !== 0) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ void getCurrentWindow()
+ .startResizeDragging(direction)
+ .catch((error: unknown) => {
+ console.error("Failed to start window resize:", error);
+ });
+ };
+
+ return (
+ <>
+ {HANDLES.map((handle) => (
+
+ ))}
+ >
+ );
+}
diff --git a/src/hooks/use-window-decorations.ts b/src/hooks/use-window-decorations.ts
new file mode 100644
index 0000000..d753f52
--- /dev/null
+++ b/src/hooks/use-window-decorations.ts
@@ -0,0 +1,76 @@
+"use client";
+
+import { invoke } from "@tauri-apps/api/core";
+import { listen } from "@tauri-apps/api/event";
+import { useEffect, useState } from "react";
+import {
+ type DecorationLayout,
+ parseDecorationLayout,
+ type WindowDecorationsInfo,
+} from "@/lib/window-decorations";
+
+export interface WindowDecorationsState {
+ /** True when the app owns the titlebar and must draw controls and edges. */
+ clientSide: boolean;
+ /** Which controls go on which side. Meaningless unless `clientSide`. */
+ layout: DecorationLayout;
+ /** False until the backend has answered. */
+ resolved: boolean;
+}
+
+const PENDING: WindowDecorationsState = {
+ clientSide: false,
+ layout: { left: [], right: [] },
+ resolved: false,
+};
+
+/**
+ * The window's decoration state, shared by everything that has to agree about
+ * it.
+ *
+ * The controls and the header padding that clears them are rendered by two
+ * different components. Fetching this separately in each let them disagree for
+ * a frame — or indefinitely, if one missed the change event — and the visible
+ * result is window controls sitting on top of the search box. One subscription
+ * per consumer, one shared derivation.
+ */
+export function useWindowDecorations(): WindowDecorationsState {
+ const [state, setState] = useState(PENDING);
+
+ useEffect(() => {
+ let cancelled = false;
+ const read = async () => {
+ try {
+ const value = await invoke(
+ "get_window_decoration_layout",
+ );
+ if (cancelled) return;
+ setState({
+ clientSide: value.client_side,
+ layout: parseDecorationLayout(value.layout),
+ resolved: true,
+ });
+ } catch (error) {
+ console.error("Failed to read window decoration layout:", error);
+ // Assume the platform still draws the titlebar: drawing a second one
+ // over a real one is worse than drawing none.
+ if (!cancelled) {
+ setState({ ...PENDING, resolved: true });
+ }
+ }
+ };
+ void read();
+ // The user can rearrange titlebar buttons while the app is running.
+ const unlisten = listen("window-decoration-layout-changed", () => {
+ void read();
+ });
+ return () => {
+ cancelled = true;
+ void unlisten.then((fn) => {
+ fn();
+ });
+ };
+ }, []);
+
+ return state;
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index f67de64..3655cfe 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -92,7 +92,8 @@
"window": {
"minimize": "Minimize",
"maximize": "Maximize",
- "restore": "Restore"
+ "restore": "Restore",
+ "close": "Close window"
},
"commandPalette": {
"title": "Command Palette",
@@ -467,7 +468,7 @@
"ssCipherRequired": "Cipher and password are required for Shadowsocks",
"selectType": "Select proxy type",
"saveFailed": "Failed to save proxy: {{error}}",
- "vlessType": "VLESS · Vision · REALITY",
+ "vlessType": "VLESS",
"vlessUri": "VLESS URI",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Requires XTLS Vision and REALITY.",
@@ -1866,6 +1867,7 @@
"remoteNoCapacity": "No remote host is free right now. Try again in a few minutes.",
"remoteNotEntitled": "Your plan does not include remote execution.",
"remoteInteractiveNotEntitled": "Your plan includes remote hours for the Cookie Bot only, not for hands-on remote sessions.",
+ "remoteRequiresRemoteExitNode": "This profile's proxy only works on this computer (for example 127.0.0.1 or a home network address). A remote session runs on our hosts, so it needs a proxy with a public address.",
"remoteSessionRefused": "The remote host refused this session.",
"remoteSessionNotFound": "That remote session no longer exists.",
"remoteSessionConflict": "This profile is already open somewhere else.",
@@ -1886,6 +1888,7 @@
"cookieBotUnknownPlatform": "This profile has no recorded operating system, so it cannot be matched to a host.",
"cookieBotUnsupportedPlatform": "The cookie bot cannot run {{platform}} profiles. Only Windows and macOS profiles are supported.",
"cookieBotRequiresExitNode": "Attach a proxy or VPN first. Without one the run would come from a datacenter address, which damages the profile's identity.",
+ "cookieBotRequiresRemoteExitNode": "This profile's proxy only works on this computer (for example 127.0.0.1 or a home network address). Cookie Bot runs on our hosts, so it needs a proxy with a public address.",
"unknownCode": "Something went wrong: {{code}}",
"cookieBotTouchFingerprintUnsupported": "This profile claims a touch device, which the bot cannot drive. Use a desktop fingerprint.",
"profileRunningRemotely": "This profile is running on a remote machine. Stop the remote session first.",
@@ -1896,7 +1899,22 @@
"fingerprintExitMismatch": "The proxy exit node doesn't match this profile's fingerprint.",
"launchConsentExpired": "That confirmation is no longer valid. Try launching again.",
"vpnWorkerStartFailed": "Couldn't start the VPN connection: {{detail}}",
- "exitProbeFailed": "Couldn't reach the proxy exit node to check its location."
+ "exitProbeFailed": "Couldn't reach the proxy exit node to check its location.",
+ "vlessUnsupported": {
+ "security": "This VLESS server does not use REALITY. Donut supports VLESS with REALITY only.",
+ "flow": "This VLESS server does not use XTLS Vision flow, which Donut requires.",
+ "transport": "Donut supports VLESS over plain TCP only — this server uses a different transport (such as WebSocket or gRPC).",
+ "encryption": "This VLESS server uses an encryption setting Donut does not support.",
+ "headerType": "This VLESS server uses a header obfuscation Donut does not support.",
+ "fingerprint": "This VLESS URI requests a TLS fingerprint Donut does not support.",
+ "sni": "The VLESS URI is missing the SNI (sni) needed for REALITY.",
+ "publicKey": "The VLESS URI is missing the REALITY public key (pbk).",
+ "scheme": "That is not a VLESS link. It must start with vless://.",
+ "parameter": "The VLESS URI contains an option Donut does not support.",
+ "malformed": "The VLESS URI is invalid."
+ },
+ "camoufoxRemoved": "Camoufox is no longer supported. Recreate this profile with Wayfern.",
+ "noE2ePasswordSet": "No end-to-end encryption password is set. Set one before syncing encrypted data."
},
"rail": {
"profiles": "Profiles",
@@ -2342,13 +2360,48 @@
"confirmBulkButton_other": "Continue with {{count}} profiles",
"sitesRequired": "Add at least one site.",
"addSitesFirst": "Add a site first",
- "presetsMissing": "Depth presets unavailable"
+ "presetsMissing": "Depth presets unavailable",
+ "sourceOwn": "My own list",
+ "sourceCurated": "Curated",
+ "sourceSaved": "Saved",
+ "curatedEmpty": "No curated lists are available right now.",
+ "curatedNote": "A curated list we keep up to date. Every profile draws its own sample from it, so no two profiles browse the same set — which is what stops the list itself becoming a signature. The addresses stay on our side.",
+ "savedEmpty": "Nothing saved yet. Type a list under My own list, then save it from there.",
+ "savedNote": "The sites are copied onto the schedule when you save it, so editing a list later leaves existing schedules alone.",
+ "savedUnavailable": "Could not load your saved lists.",
+ "saveAsList": "Save as a list",
+ "listNamePlaceholder": "Name this list",
+ "listSaved": "List saved",
+ "listRenamed": "List renamed",
+ "listDeleted": "List deleted",
+ "listRename": "Rename",
+ "listDeleteConfirm": "Delete?",
+ "templateMissing": "That saved list no longer exists. Pick another one.",
+ "templateNameTaken": "You already have a list with that name.",
+ "templateNameInvalid": "Give the list a name of {{max}} characters or fewer.",
+ "calendarLabel": "When it runs",
+ "daysLabel": "Days of the week",
+ "addSlot": "Add a time",
+ "slotsFull": "At most {{max}} start times.",
+ "removeSlot": "Remove this time",
+ "pickListFirst": "Pick a list first",
+ "finishCalendarFirst": "Finish the schedule first",
+ "duplicateSlot": "Two rows have the same days and time",
+ "listSites_one": "{{count}} site",
+ "listSites_other": "{{count}} sites",
+ "summarySlots_one": "Runs {{count}} time a week, up to {{minutes}} min each.",
+ "summarySlots_other": "Runs {{count}} times a week, up to {{minutes}} min each.",
+ "templateNameInvalidNoMax": "That name can't be used for a saved list."
},
"preset": {
"light": "Light",
"balanced": "Standard",
"deep": "Deep"
},
+ "template": {
+ "lowIntentPurchaser": "Low-intent purchaser",
+ "lowIntentPurchaserHint": "Positions the profile as a price-sensitive buyer: comparison, coupon, cashback and resale sites, reaching retailers through aggregators rather than directly."
+ },
"preflight": {
"ineligible_one": "{{count}} profile can't run remotely",
"ineligible_other": "{{count}} profiles can't run remotely",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 22f3be9..223f50e 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -92,7 +92,8 @@
"window": {
"minimize": "Minimizar",
"maximize": "Maximizar",
- "restore": "Restaurar"
+ "restore": "Restaurar",
+ "close": "Cerrar ventana"
},
"commandPalette": {
"title": "Paleta de comandos",
@@ -468,7 +469,7 @@
"ssCipherRequired": "Para Shadowsocks se requieren cifrado y contraseña",
"selectType": "Selecciona el tipo de proxy",
"saveFailed": "Error al guardar el proxy: {{error}}",
- "vlessType": "VLESS · Vision · REALITY",
+ "vlessType": "VLESS",
"vlessUri": "URI de VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Requiere XTLS Vision y REALITY.",
@@ -1873,6 +1874,7 @@
"remoteNoCapacity": "Ahora mismo no hay ninguna máquina remota libre. Inténtalo de nuevo en unos minutos.",
"remoteNotEntitled": "Tu plan no incluye la ejecución remota.",
"remoteInteractiveNotEntitled": "Tu plan incluye horas remotas solo para el Cookie Bot, no para sesiones remotas interactivas.",
+ "remoteRequiresRemoteExitNode": "El proxy de este perfil solo funciona en este ordenador (por ejemplo 127.0.0.1 o una dirección de red local). Las sesiones remotas se ejecutan en nuestros servidores, así que necesitan un proxy con dirección pública.",
"remoteSessionRefused": "La máquina remota rechazó esta sesión.",
"remoteSessionNotFound": "Esa sesión remota ya no existe.",
"remoteSessionConflict": "Este perfil ya está abierto en otro sitio.",
@@ -1893,6 +1895,7 @@
"cookieBotUnknownPlatform": "Este perfil no tiene un sistema operativo registrado, así que no se puede asignar a ninguna máquina.",
"cookieBotUnsupportedPlatform": "Cookie Bot no puede ejecutar perfiles de {{platform}}. Solo se admiten perfiles de Windows y macOS.",
"cookieBotRequiresExitNode": "Asigna primero un proxy o una VPN. Sin ninguno, la ejecución saldría desde una dirección de centro de datos, lo que daña la identidad del perfil.",
+ "cookieBotRequiresRemoteExitNode": "El proxy de este perfil solo funciona en este ordenador (por ejemplo 127.0.0.1 o una dirección de red local). Cookie Bot se ejecuta en nuestros servidores, así que necesita un proxy con dirección pública.",
"unknownCode": "Algo salió mal: {{code}}",
"cookieBotTouchFingerprintUnsupported": "Este perfil declara un dispositivo táctil, que el bot no puede controlar. Usa una huella de escritorio.",
"profileRunningRemotely": "Este perfil se está ejecutando en una máquina remota. Detén primero la sesión remota.",
@@ -1903,7 +1906,22 @@
"fingerprintExitMismatch": "El nodo de salida del proxy no coincide con la huella digital de este perfil.",
"launchConsentExpired": "Esa confirmación ya no es válida. Vuelve a iniciar.",
"vpnWorkerStartFailed": "No se pudo iniciar la conexión VPN: {{detail}}",
- "exitProbeFailed": "No se pudo contactar con el nodo de salida del proxy para comprobar su ubicación."
+ "exitProbeFailed": "No se pudo contactar con el nodo de salida del proxy para comprobar su ubicación.",
+ "vlessUnsupported": {
+ "security": "Este servidor VLESS no usa REALITY. Donut solo admite VLESS con REALITY.",
+ "flow": "Este servidor VLESS no usa el flujo XTLS Vision, que Donut requiere.",
+ "transport": "Donut solo admite VLESS sobre TCP simple: este servidor usa otro transporte (como WebSocket o gRPC).",
+ "encryption": "Este servidor VLESS usa un cifrado que Donut no admite.",
+ "headerType": "Este servidor VLESS usa una ofuscación de cabecera que Donut no admite.",
+ "fingerprint": "Esta URI VLESS solicita una huella TLS que Donut no admite.",
+ "sni": "A la URI VLESS le falta el SNI (sni) necesario para REALITY.",
+ "publicKey": "A la URI VLESS le falta la clave pública de REALITY (pbk).",
+ "scheme": "Eso no es un enlace VLESS. Debe empezar por vless://.",
+ "parameter": "La URI VLESS contiene una opción que Donut no admite.",
+ "malformed": "La URI VLESS no es válida."
+ },
+ "camoufoxRemoved": "Camoufox ya no es compatible. Vuelve a crear este perfil con Wayfern.",
+ "noE2ePasswordSet": "No hay contraseña de cifrado de extremo a extremo. Establece una antes de sincronizar datos cifrados."
},
"rail": {
"profiles": "Perfiles",
@@ -2367,13 +2385,50 @@
"confirmBulkButton_many": "Continuar con {{count}} perfiles",
"sitesRequired": "Añade al menos un sitio.",
"addSitesFirst": "Añade un sitio primero",
- "presetsMissing": "Ajustes de profundidad no disponibles"
+ "presetsMissing": "Ajustes de profundidad no disponibles",
+ "sourceOwn": "Mi propia lista",
+ "sourceCurated": "Seleccionadas",
+ "sourceSaved": "Guardadas",
+ "curatedEmpty": "Ahora mismo no hay listas seleccionadas disponibles.",
+ "curatedNote": "Una lista seleccionada que mantenemos al día. Cada perfil toma su propia muestra de ella, así que no hay dos perfiles que naveguen el mismo conjunto, y por eso la lista no llega a convertirse en una firma. Las direcciones se quedan de nuestro lado.",
+ "savedEmpty": "Todavía no has guardado nada. Escribe una lista en Mi propia lista y guárdala desde allí.",
+ "savedNote": "Los sitios se copian en la programación al guardarla, así que editar una lista más tarde no afecta a las programaciones existentes.",
+ "savedUnavailable": "No se pudieron cargar tus listas guardadas.",
+ "saveAsList": "Guardar como lista",
+ "listNamePlaceholder": "Nombra esta lista",
+ "listSaved": "Lista guardada",
+ "listRenamed": "Lista renombrada",
+ "listDeleted": "Lista eliminada",
+ "listRename": "Renombrar",
+ "listDeleteConfirm": "¿Eliminar?",
+ "templateMissing": "Esa lista guardada ya no existe. Elige otra.",
+ "templateNameTaken": "Ya tienes una lista con ese nombre.",
+ "templateNameInvalid": "Ponle a la lista un nombre de {{max}} caracteres o menos.",
+ "calendarLabel": "Cuándo se ejecuta",
+ "daysLabel": "Días de la semana",
+ "addSlot": "Añadir una hora",
+ "slotsFull": "Como máximo {{max}} horas de inicio.",
+ "removeSlot": "Quitar esta hora",
+ "pickListFirst": "Elige una lista primero",
+ "finishCalendarFirst": "Termina la programación primero",
+ "duplicateSlot": "Dos filas tienen los mismos días y la misma hora",
+ "listSites_one": "{{count}} sitio",
+ "listSites_other": "{{count}} sitios",
+ "listSites_many": "{{count}} sitios",
+ "summarySlots_one": "Se ejecuta {{count}} vez a la semana, hasta {{minutes}} min cada vez.",
+ "summarySlots_other": "Se ejecuta {{count}} veces a la semana, hasta {{minutes}} min cada vez.",
+ "summarySlots_many": "Se ejecuta {{count}} veces a la semana, hasta {{minutes}} min cada vez.",
+ "templateNameInvalidNoMax": "Ese nombre no se puede usar para una lista guardada."
},
"preset": {
"light": "Ligera",
"balanced": "Estándar",
"deep": "Profunda"
},
+ "template": {
+ "lowIntentPurchaser": "Comprador de baja intención",
+ "lowIntentPurchaserHint": "Posiciona el perfil como un comprador sensible al precio: sitios de comparación, cupones, reembolsos y reventa, llegando a las tiendas a través de agregadores en lugar de directamente."
+ },
"preflight": {
"ineligible_one": "{{count}} perfil no puede ejecutarse en remoto",
"ineligible_other": "{{count}} perfiles no pueden ejecutarse en remoto",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 2108898..d897747 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -92,7 +92,8 @@
"window": {
"minimize": "Réduire",
"maximize": "Agrandir",
- "restore": "Restaurer"
+ "restore": "Restaurer",
+ "close": "Fermer la fenêtre"
},
"commandPalette": {
"title": "Palette de commandes",
@@ -468,7 +469,7 @@
"ssCipherRequired": "Le chiffrement et le mot de passe sont requis pour Shadowsocks",
"selectType": "Sélectionnez le type de proxy",
"saveFailed": "Échec de la sauvegarde du proxy : {{error}}",
- "vlessType": "VLESS · Vision · REALITY",
+ "vlessType": "VLESS",
"vlessUri": "URI VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Nécessite XTLS Vision et REALITY.",
@@ -1873,6 +1874,7 @@
"remoteNoCapacity": "Aucune machine distante n'est libre pour le moment. Réessayez dans quelques minutes.",
"remoteNotEntitled": "Votre forfait n'inclut pas l'exécution à distance.",
"remoteInteractiveNotEntitled": "Votre forfait inclut des heures distantes uniquement pour le Cookie Bot, pas pour les sessions distantes interactives.",
+ "remoteRequiresRemoteExitNode": "Le proxy de ce profil ne fonctionne que sur cet ordinateur (par exemple 127.0.0.1 ou une adresse de réseau local). Les sessions distantes s'exécutent sur nos hôtes et ont donc besoin d'un proxy avec une adresse publique.",
"remoteSessionRefused": "La machine distante a refusé cette session.",
"remoteSessionNotFound": "Cette session distante n'existe plus.",
"remoteSessionConflict": "Ce profil est déjà ouvert ailleurs.",
@@ -1893,6 +1895,7 @@
"cookieBotUnknownPlatform": "Ce profil n'a aucun système d'exploitation enregistré, il ne peut donc pas être associé à une machine.",
"cookieBotUnsupportedPlatform": "Cookie Bot ne peut pas exécuter de profils {{platform}}. Seuls les profils Windows et macOS sont pris en charge.",
"cookieBotRequiresExitNode": "Associez d'abord un proxy ou un VPN. Sans cela, l'exécution proviendrait d'une adresse de centre de données, ce qui abîme l'identité du profil.",
+ "cookieBotRequiresRemoteExitNode": "Le proxy de ce profil ne fonctionne que sur cet ordinateur (par exemple 127.0.0.1 ou une adresse de réseau local). Cookie Bot s'exécute sur nos hôtes et a donc besoin d'un proxy avec une adresse publique.",
"unknownCode": "Une erreur est survenue : {{code}}",
"cookieBotTouchFingerprintUnsupported": "Ce profil déclare un appareil tactile, que le bot ne peut pas piloter. Utilisez une empreinte de bureau.",
"profileRunningRemotely": "Ce profil s'exécute sur une machine distante. Arrêtez d'abord la session distante.",
@@ -1903,7 +1906,22 @@
"fingerprintExitMismatch": "Le nœud de sortie du proxy ne correspond pas à l'empreinte de ce profil.",
"launchConsentExpired": "Cette confirmation n'est plus valide. Relancez le profil.",
"vpnWorkerStartFailed": "Impossible de démarrer la connexion VPN : {{detail}}",
- "exitProbeFailed": "Impossible de joindre le nœud de sortie du proxy pour vérifier sa localisation."
+ "exitProbeFailed": "Impossible de joindre le nœud de sortie du proxy pour vérifier sa localisation.",
+ "vlessUnsupported": {
+ "security": "Ce serveur VLESS n'utilise pas REALITY. Donut ne prend en charge que VLESS avec REALITY.",
+ "flow": "Ce serveur VLESS n'utilise pas le flux XTLS Vision, requis par Donut.",
+ "transport": "Donut ne prend en charge que VLESS sur TCP simple — ce serveur utilise un autre transport (WebSocket ou gRPC, par exemple).",
+ "encryption": "Ce serveur VLESS utilise un chiffrement non pris en charge par Donut.",
+ "headerType": "Ce serveur VLESS utilise une obfuscation d'en-tête non prise en charge par Donut.",
+ "fingerprint": "Cette URI VLESS demande une empreinte TLS non prise en charge par Donut.",
+ "sni": "Il manque le SNI (sni) nécessaire à REALITY dans l'URI VLESS.",
+ "publicKey": "Il manque la clé publique REALITY (pbk) dans l'URI VLESS.",
+ "scheme": "Ce n'est pas un lien VLESS. Il doit commencer par vless://.",
+ "parameter": "L'URI VLESS contient une option non prise en charge par Donut.",
+ "malformed": "L'URI VLESS n'est pas valide."
+ },
+ "camoufoxRemoved": "Camoufox n'est plus pris en charge. Recréez ce profil avec Wayfern.",
+ "noE2ePasswordSet": "Aucun mot de passe de chiffrement de bout en bout n'est défini. Définissez-en un avant de synchroniser des données chiffrées."
},
"rail": {
"profiles": "Profils",
@@ -2367,13 +2385,50 @@
"confirmBulkButton_many": "Continuer avec {{count}} profils",
"sitesRequired": "Ajoutez au moins un site.",
"addSitesFirst": "Ajoutez d'abord un site",
- "presetsMissing": "Préréglages de profondeur indisponibles"
+ "presetsMissing": "Préréglages de profondeur indisponibles",
+ "sourceOwn": "Ma propre liste",
+ "sourceCurated": "Sélection",
+ "sourceSaved": "Enregistrées",
+ "curatedEmpty": "Aucune liste de la sélection n'est disponible pour le moment.",
+ "curatedNote": "Une liste que nous tenons à jour. Chaque profil en tire son propre échantillon : deux profils ne parcourent donc jamais le même ensemble, ce qui empêche la liste elle-même de devenir une signature. Les adresses restent de notre côté.",
+ "savedEmpty": "Rien d'enregistré pour l'instant. Saisissez une liste dans Ma propre liste, puis enregistrez-la depuis là.",
+ "savedNote": "Les sites sont copiés dans la planification au moment de l'enregistrement : modifier une liste plus tard ne touche pas aux planifications existantes.",
+ "savedUnavailable": "Impossible de charger vos listes enregistrées.",
+ "saveAsList": "Enregistrer comme liste",
+ "listNamePlaceholder": "Nommez cette liste",
+ "listSaved": "Liste enregistrée",
+ "listRenamed": "Liste renommée",
+ "listDeleted": "Liste supprimée",
+ "listRename": "Renommer",
+ "listDeleteConfirm": "Supprimer ?",
+ "templateMissing": "Cette liste enregistrée n'existe plus. Choisissez-en une autre.",
+ "templateNameTaken": "Vous avez déjà une liste portant ce nom.",
+ "templateNameInvalid": "Donnez à la liste un nom de {{max}} caractères maximum.",
+ "calendarLabel": "Quand il s'exécute",
+ "daysLabel": "Jours de la semaine",
+ "addSlot": "Ajouter une heure",
+ "slotsFull": "{{max}} heures de départ au maximum.",
+ "removeSlot": "Retirer cette heure",
+ "pickListFirst": "Choisissez d'abord une liste",
+ "finishCalendarFirst": "Terminez d'abord la planification",
+ "duplicateSlot": "Deux lignes ont les mêmes jours et la même heure",
+ "listSites_one": "{{count}} site",
+ "listSites_other": "{{count}} sites",
+ "listSites_many": "{{count}} sites",
+ "summarySlots_one": "S'exécute {{count}} fois par semaine, jusqu'à {{minutes}} min à chaque fois.",
+ "summarySlots_other": "S'exécute {{count}} fois par semaine, jusqu'à {{minutes}} min à chaque fois.",
+ "summarySlots_many": "S'exécute {{count}} fois par semaine, jusqu'à {{minutes}} min à chaque fois.",
+ "templateNameInvalidNoMax": "Ce nom ne peut pas être utilisé pour une liste enregistrée."
},
"preset": {
"light": "Légère",
"balanced": "Standard",
"deep": "Approfondie"
},
+ "template": {
+ "lowIntentPurchaser": "Acheteur à faible intention",
+ "lowIntentPurchaserHint": "Positionne le profil comme un acheteur sensible au prix : comparateurs, coupons, cashback et revente, en arrivant chez les marchands via des agrégateurs plutôt qu'en direct."
+ },
"preflight": {
"ineligible_one": "{{count}} profil ne peut pas s'exécuter à distance",
"ineligible_other": "{{count}} profils ne peuvent pas s'exécuter à distance",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 8db4c89..2695926 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -92,7 +92,8 @@
"window": {
"minimize": "最小化",
"maximize": "最大化",
- "restore": "元に戻す"
+ "restore": "元に戻す",
+ "close": "ウィンドウを閉じる"
},
"commandPalette": {
"title": "コマンドパレット",
@@ -467,7 +468,7 @@
"ssCipherRequired": "Shadowsocks には暗号とパスワードが必要です",
"selectType": "プロキシの種類を選択",
"saveFailed": "プロキシの保存に失敗しました: {{error}}",
- "vlessType": "VLESS · Vision · REALITY",
+ "vlessType": "VLESS",
"vlessUri": "VLESS URI",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "XTLS Vision と REALITY が必要です。",
@@ -1866,6 +1867,7 @@
"remoteNoCapacity": "現在空いているリモートマシンがありません。数分後にもう一度お試しください。",
"remoteNotEntitled": "ご利用のプランにはリモート実行が含まれていません。",
"remoteInteractiveNotEntitled": "ご利用のプランのリモート時間は Cookie Bot 専用で、手動のリモートセッションには使えません。",
+ "remoteRequiresRemoteExitNode": "このプロファイルのプロキシはこのコンピューター上でのみ有効です(127.0.0.1 やローカルネットワークのアドレスなど)。リモートセッションは当社のホスト上で実行されるため、公開アドレスを持つプロキシが必要です。",
"remoteSessionRefused": "リモートマシンがこのセッションを拒否しました。",
"remoteSessionNotFound": "そのリモートセッションはすでに存在しません。",
"remoteSessionConflict": "このプロファイルはすでに別の場所で開かれています。",
@@ -1886,6 +1888,7 @@
"cookieBotUnknownPlatform": "このプロファイルには OS が記録されていないため、マシンを割り当てられません。",
"cookieBotUnsupportedPlatform": "Cookie Bot は {{platform}} のプロファイルを実行できません。対応しているのは Windows と macOS のプロファイルのみです。",
"cookieBotRequiresExitNode": "先にプロキシまたは VPN を設定してください。設定しないと通信がデータセンターのアドレスから出て、プロファイルの信頼性を損ないます。",
+ "cookieBotRequiresRemoteExitNode": "このプロファイルのプロキシはこのコンピューター上でのみ有効です(127.0.0.1 やローカルネットワークのアドレスなど)。Cookie Bot は当社のホスト上で実行されるため、公開アドレスを持つプロキシが必要です。",
"unknownCode": "エラーが発生しました: {{code}}",
"cookieBotTouchFingerprintUnsupported": "このプロファイルはタッチ端末を名乗っており、ボットは操作できません。デスクトップのフィンガープリントをお使いください。",
"profileRunningRemotely": "このプロファイルはリモートマシンで実行中です。先にリモートセッションを停止してください。",
@@ -1896,7 +1899,22 @@
"fingerprintExitMismatch": "プロキシの出口ノードがこのプロファイルのフィンガープリントと一致しません。",
"launchConsentExpired": "この確認は無効になりました。もう一度起動してください。",
"vpnWorkerStartFailed": "VPN接続を開始できませんでした: {{detail}}",
- "exitProbeFailed": "プロキシの出口ノードに接続できず、所在地を確認できませんでした。"
+ "exitProbeFailed": "プロキシの出口ノードに接続できず、所在地を確認できませんでした。",
+ "vlessUnsupported": {
+ "security": "このVLESSサーバーはREALITYを使用していません。DonutはREALITY付きのVLESSのみに対応しています。",
+ "flow": "このVLESSサーバーは、Donutが必要とするXTLS Visionフローを使用していません。",
+ "transport": "Donutは素のTCP上のVLESSのみに対応しています。このサーバーは別のトランスポート(WebSocketやgRPCなど)を使用しています。",
+ "encryption": "このVLESSサーバーは、Donutが対応していない暗号化設定を使用しています。",
+ "headerType": "このVLESSサーバーは、Donutが対応していないヘッダー難読化を使用しています。",
+ "fingerprint": "このVLESS URIは、Donutが対応していないTLSフィンガープリントを要求しています。",
+ "sni": "VLESS URIに、REALITYに必要なSNI(sni)がありません。",
+ "publicKey": "VLESS URIに、REALITYの公開鍵(pbk)がありません。",
+ "scheme": "これはVLESSリンクではありません。vless:// で始まる必要があります。",
+ "parameter": "VLESS URIに、Donutが対応していないオプションが含まれています。",
+ "malformed": "VLESS URIが無効です。"
+ },
+ "camoufoxRemoved": "Camoufoxはサポートされなくなりました。Wayfernでこのプロファイルを作り直してください。",
+ "noE2ePasswordSet": "エンドツーエンド暗号化のパスワードが設定されていません。暗号化データを同期する前に設定してください。"
},
"rail": {
"profiles": "プロファイル",
@@ -2342,13 +2360,48 @@
"confirmBulkButton_other": "{{count}} 件のプロファイルで続行",
"sitesRequired": "サイトを 1 件以上追加してください。",
"addSitesFirst": "先にサイトを追加してください",
- "presetsMissing": "深さのプリセットを利用できません"
+ "presetsMissing": "深さのプリセットを利用できません",
+ "sourceOwn": "自分のリスト",
+ "sourceCurated": "厳選リスト",
+ "sourceSaved": "保存済み",
+ "curatedEmpty": "現在利用できる厳選リストはありません。",
+ "curatedNote": "こちらで最新に保っている厳選リストです。プロファイルごとに異なるサンプルを抽出するため、同じ組み合わせを閲覧するプロファイルは 2 つとありません。これがリスト自体を特徴にさせない仕組みです。アドレスはサーバー側に留まります。",
+ "savedEmpty": "まだ保存されていません。「自分のリスト」でリストを入力し、そこから保存してください。",
+ "savedNote": "サイトは保存時にスケジュールへコピーされます。後からリストを編集しても既存のスケジュールは変わりません。",
+ "savedUnavailable": "保存済みのリストを読み込めませんでした。",
+ "saveAsList": "リストとして保存",
+ "listNamePlaceholder": "リスト名を入力",
+ "listSaved": "リストを保存しました",
+ "listRenamed": "リスト名を変更しました",
+ "listDeleted": "リストを削除しました",
+ "listRename": "名前を変更",
+ "listDeleteConfirm": "削除しますか?",
+ "templateMissing": "その保存済みリストは存在しません。別のものを選んでください。",
+ "templateNameTaken": "同じ名前のリストがすでにあります。",
+ "templateNameInvalid": "リスト名は {{max}} 文字以内にしてください。",
+ "calendarLabel": "実行するタイミング",
+ "daysLabel": "曜日",
+ "addSlot": "時刻を追加",
+ "slotsFull": "開始時刻は最大 {{max}} 件です。",
+ "removeSlot": "この時刻を削除",
+ "pickListFirst": "先にリストを選択",
+ "finishCalendarFirst": "先にスケジュールを完成させてください",
+ "duplicateSlot": "2 つの行の曜日と時刻が同じです",
+ "listSites_one": "{{count}} サイト",
+ "listSites_other": "{{count}} サイト",
+ "summarySlots_one": "週 {{count}} 回、1 回あたり最大 {{minutes}} 分実行します。",
+ "summarySlots_other": "週 {{count}} 回、1 回あたり最大 {{minutes}} 分実行します。",
+ "templateNameInvalidNoMax": "その名前は保存済みリストには使用できません。"
},
"preset": {
"light": "軽め",
"balanced": "標準",
"deep": "深め"
},
+ "template": {
+ "lowIntentPurchaser": "低購買意欲の買い物客",
+ "lowIntentPurchaserHint": "価格に敏感な買い手としてプロファイルを位置づけます。比較・クーポン・キャッシュバック・リセールのサイトを巡り、直接ではなく集約サイト経由で小売サイトに到達します。"
+ },
"preflight": {
"ineligible_one": "{{count}} 件のプロファイルはリモートで実行できません",
"ineligible_other": "{{count}} 件のプロファイルはリモートで実行できません",
diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json
index 6678f06..7dc4776 100644
--- a/src/i18n/locales/ko.json
+++ b/src/i18n/locales/ko.json
@@ -92,7 +92,8 @@
"window": {
"minimize": "최소화",
"maximize": "최대화",
- "restore": "이전 크기로 복원"
+ "restore": "이전 크기로 복원",
+ "close": "창 닫기"
},
"commandPalette": {
"title": "명령 팔레트",
@@ -467,7 +468,7 @@
"ssCipherRequired": "Shadowsocks에는 암호화와 비밀번호가 필요합니다",
"selectType": "프록시 유형 선택",
"saveFailed": "프록시 저장 실패: {{error}}",
- "vlessType": "VLESS · Vision · REALITY",
+ "vlessType": "VLESS",
"vlessUri": "VLESS URI",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "XTLS Vision 및 REALITY가 필요합니다.",
@@ -1866,6 +1867,7 @@
"remoteNoCapacity": "지금은 사용 가능한 원격 머신이 없습니다. 몇 분 후에 다시 시도하세요.",
"remoteNotEntitled": "현재 요금제에는 원격 실행이 포함되어 있지 않습니다.",
"remoteInteractiveNotEntitled": "현재 플랜의 원격 시간은 Cookie Bot 전용이며, 직접 조작하는 원격 세션에는 사용할 수 없습니다.",
+ "remoteRequiresRemoteExitNode": "이 프로필의 프록시는 이 컴퓨터에서만 작동합니다(예: 127.0.0.1 또는 사설망 주소). 원격 세션은 당사 호스트에서 실행되므로 공용 주소를 가진 프록시가 필요합니다.",
"remoteSessionRefused": "원격 머신이 이 세션을 거부했습니다.",
"remoteSessionNotFound": "해당 원격 세션은 더 이상 존재하지 않습니다.",
"remoteSessionConflict": "이 프로필은 이미 다른 곳에서 열려 있습니다.",
@@ -1886,6 +1888,7 @@
"cookieBotUnknownPlatform": "이 프로필에는 기록된 운영체제가 없어 머신을 배정할 수 없습니다.",
"cookieBotUnsupportedPlatform": "Cookie Bot은 {{platform}} 프로필을 실행할 수 없습니다. Windows와 macOS 프로필만 지원합니다.",
"cookieBotRequiresExitNode": "먼저 프록시나 VPN을 연결하세요. 없으면 실행 트래픽이 데이터센터 주소에서 나가 프로필 신뢰도를 해칩니다.",
+ "cookieBotRequiresRemoteExitNode": "이 프로필의 프록시는 이 컴퓨터에서만 작동합니다(예: 127.0.0.1 또는 사설망 주소). Cookie Bot은 당사 호스트에서 실행되므로 공용 주소를 가진 프록시가 필요합니다.",
"unknownCode": "문제가 발생했습니다: {{code}}",
"cookieBotTouchFingerprintUnsupported": "이 프로필은 터치 기기를 표방하며, 봇이 조작할 수 없습니다. 데스크톱 지문을 사용하세요.",
"profileRunningRemotely": "이 프로필은 원격 머신에서 실행 중입니다. 먼저 원격 세션을 중지하세요.",
@@ -1896,7 +1899,22 @@
"fingerprintExitMismatch": "프록시 출구 노드가 이 프로필의 핑거프린트와 일치하지 않습니다.",
"launchConsentExpired": "해당 확인이 더 이상 유효하지 않습니다. 다시 실행해 보세요.",
"vpnWorkerStartFailed": "VPN 연결을 시작하지 못했습니다: {{detail}}",
- "exitProbeFailed": "프록시 출구 노드에 연결할 수 없어 위치를 확인하지 못했습니다."
+ "exitProbeFailed": "프록시 출구 노드에 연결할 수 없어 위치를 확인하지 못했습니다.",
+ "vlessUnsupported": {
+ "security": "이 VLESS 서버는 REALITY를 사용하지 않습니다. Donut은 REALITY를 사용하는 VLESS만 지원합니다.",
+ "flow": "이 VLESS 서버는 Donut이 요구하는 XTLS Vision 플로우를 사용하지 않습니다.",
+ "transport": "Donut은 일반 TCP 기반 VLESS만 지원합니다. 이 서버는 다른 전송 방식(WebSocket, gRPC 등)을 사용합니다.",
+ "encryption": "이 VLESS 서버는 Donut이 지원하지 않는 암호화 설정을 사용합니다.",
+ "headerType": "이 VLESS 서버는 Donut이 지원하지 않는 헤더 난독화를 사용합니다.",
+ "fingerprint": "이 VLESS URI는 Donut이 지원하지 않는 TLS 지문을 요청합니다.",
+ "sni": "VLESS URI에 REALITY에 필요한 SNI(sni)가 없습니다.",
+ "publicKey": "VLESS URI에 REALITY 공개 키(pbk)가 없습니다.",
+ "scheme": "VLESS 링크가 아닙니다. vless:// 로 시작해야 합니다.",
+ "parameter": "VLESS URI에 Donut이 지원하지 않는 옵션이 있습니다.",
+ "malformed": "VLESS URI가 올바르지 않습니다."
+ },
+ "camoufoxRemoved": "Camoufox는 더 이상 지원되지 않습니다. Wayfern으로 이 프로필을 다시 만드세요.",
+ "noE2ePasswordSet": "종단 간 암호화 비밀번호가 설정되지 않았습니다. 암호화된 데이터를 동기화하기 전에 설정하세요."
},
"rail": {
"profiles": "프로필",
@@ -2342,13 +2360,48 @@
"confirmBulkButton_other": "프로필 {{count}}개로 계속",
"sitesRequired": "사이트를 하나 이상 추가하세요.",
"addSitesFirst": "사이트를 먼저 추가하세요",
- "presetsMissing": "깊이 프리셋을 사용할 수 없습니다"
+ "presetsMissing": "깊이 프리셋을 사용할 수 없습니다",
+ "sourceOwn": "내 목록",
+ "sourceCurated": "큐레이션",
+ "sourceSaved": "저장됨",
+ "curatedEmpty": "지금은 사용할 수 있는 큐레이션 목록이 없습니다.",
+ "curatedNote": "저희가 최신 상태로 관리하는 큐레이션 목록입니다. 프로필마다 서로 다른 표본을 뽑기 때문에 같은 조합을 방문하는 프로필은 없습니다. 그래서 목록 자체가 특징이 되지 않습니다. 주소는 서버에만 남습니다.",
+ "savedEmpty": "아직 저장한 것이 없습니다. 내 목록에서 목록을 입력한 뒤 거기서 저장하세요.",
+ "savedNote": "사이트는 일정을 저장할 때 복사됩니다. 나중에 목록을 수정해도 기존 일정은 그대로 유지됩니다.",
+ "savedUnavailable": "저장된 목록을 불러오지 못했습니다.",
+ "saveAsList": "목록으로 저장",
+ "listNamePlaceholder": "목록 이름",
+ "listSaved": "목록을 저장했습니다",
+ "listRenamed": "목록 이름을 변경했습니다",
+ "listDeleted": "목록을 삭제했습니다",
+ "listRename": "이름 변경",
+ "listDeleteConfirm": "삭제할까요?",
+ "templateMissing": "그 저장된 목록은 더 이상 없습니다. 다른 목록을 선택하세요.",
+ "templateNameTaken": "같은 이름의 목록이 이미 있습니다.",
+ "templateNameInvalid": "목록 이름은 {{max}}자 이하로 지어 주세요.",
+ "calendarLabel": "실행 시점",
+ "daysLabel": "요일",
+ "addSlot": "시간 추가",
+ "slotsFull": "시작 시간은 최대 {{max}}개입니다.",
+ "removeSlot": "이 시간 제거",
+ "pickListFirst": "먼저 목록을 선택하세요",
+ "finishCalendarFirst": "먼저 일정을 완성하세요",
+ "duplicateSlot": "두 행의 요일과 시간이 같습니다",
+ "listSites_one": "사이트 {{count}}개",
+ "listSites_other": "사이트 {{count}}개",
+ "summarySlots_one": "주 {{count}}회, 회당 최대 {{minutes}}분 실행합니다.",
+ "summarySlots_other": "주 {{count}}회, 회당 최대 {{minutes}}분 실행합니다.",
+ "templateNameInvalidNoMax": "저장된 목록에 그 이름은 사용할 수 없습니다."
},
"preset": {
"light": "가볍게",
"balanced": "표준",
"deep": "깊게"
},
+ "template": {
+ "lowIntentPurchaser": "구매 의향이 낮은 쇼핑객",
+ "lowIntentPurchaserHint": "가격에 민감한 구매자로 프로필을 자리매김합니다. 비교·쿠폰·캐시백·중고 거래 사이트를 이용하고, 판매점에는 직접이 아니라 집계 사이트를 거쳐 도달합니다."
+ },
"preflight": {
"ineligible_one": "프로필 {{count}}개는 원격으로 실행할 수 없습니다",
"ineligible_other": "프로필 {{count}}개는 원격으로 실행할 수 없습니다",
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index 1f51612..cabfb74 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -92,7 +92,8 @@
"window": {
"minimize": "Minimizar",
"maximize": "Maximizar",
- "restore": "Restaurar"
+ "restore": "Restaurar",
+ "close": "Fechar janela"
},
"commandPalette": {
"title": "Paleta de comandos",
@@ -468,7 +469,7 @@
"ssCipherRequired": "Cifra e senha são obrigatórias para Shadowsocks",
"selectType": "Selecione o tipo de proxy",
"saveFailed": "Falha ao salvar o proxy: {{error}}",
- "vlessType": "VLESS · Vision · REALITY",
+ "vlessType": "VLESS",
"vlessUri": "URI VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Requer XTLS Vision e REALITY.",
@@ -1873,6 +1874,7 @@
"remoteNoCapacity": "Nenhuma máquina remota está livre agora. Tente novamente em alguns minutos.",
"remoteNotEntitled": "Seu plano não inclui execução remota.",
"remoteInteractiveNotEntitled": "Seu plano inclui horas remotas apenas para o Cookie Bot, não para sessões remotas interativas.",
+ "remoteRequiresRemoteExitNode": "O proxy deste perfil só funciona neste computador (por exemplo 127.0.0.1 ou um endereço de rede local). As sessões remotas são executadas nos nossos servidores, por isso precisam de um proxy com endereço público.",
"remoteSessionRefused": "A máquina remota recusou esta sessão.",
"remoteSessionNotFound": "Essa sessão remota não existe mais.",
"remoteSessionConflict": "Este perfil já está aberto em outro lugar.",
@@ -1893,6 +1895,7 @@
"cookieBotUnknownPlatform": "Este perfil não tem sistema operacional registrado, então não é possível associá-lo a uma máquina.",
"cookieBotUnsupportedPlatform": "O Cookie Bot não pode executar perfis de {{platform}}. Somente perfis Windows e macOS são suportados.",
"cookieBotRequiresExitNode": "Anexe primeiro um proxy ou VPN. Sem isso, a execução sairia de um endereço de data center, o que prejudica a identidade do perfil.",
+ "cookieBotRequiresRemoteExitNode": "O proxy deste perfil só funciona neste computador (por exemplo 127.0.0.1 ou um endereço de rede local). O Cookie Bot é executado nos nossos servidores, por isso precisa de um proxy com endereço público.",
"unknownCode": "Algo deu errado: {{code}}",
"cookieBotTouchFingerprintUnsupported": "Este perfil declara um dispositivo de toque, que o bot não consegue controlar. Use uma impressão digital de computador.",
"profileRunningRemotely": "Este perfil está em execução numa máquina remota. Pare primeiro a sessão remota.",
@@ -1903,7 +1906,22 @@
"fingerprintExitMismatch": "O nó de saída do proxy não corresponde à impressão digital deste perfil.",
"launchConsentExpired": "Essa confirmação não é mais válida. Tente iniciar novamente.",
"vpnWorkerStartFailed": "Não foi possível iniciar a conexão VPN: {{detail}}",
- "exitProbeFailed": "Não foi possível alcançar o nó de saída do proxy para verificar sua localização."
+ "exitProbeFailed": "Não foi possível alcançar o nó de saída do proxy para verificar sua localização.",
+ "vlessUnsupported": {
+ "security": "Este servidor VLESS não usa REALITY. O Donut só oferece suporte a VLESS com REALITY.",
+ "flow": "Este servidor VLESS não usa o fluxo XTLS Vision, exigido pelo Donut.",
+ "transport": "O Donut só oferece suporte a VLESS sobre TCP simples — este servidor usa outro transporte (como WebSocket ou gRPC).",
+ "encryption": "Este servidor VLESS usa uma criptografia sem suporte no Donut.",
+ "headerType": "Este servidor VLESS usa uma ofuscação de cabeçalho sem suporte no Donut.",
+ "fingerprint": "Esta URI VLESS solicita uma impressão digital TLS sem suporte no Donut.",
+ "sni": "Falta na URI VLESS o SNI (sni) necessário para o REALITY.",
+ "publicKey": "Falta na URI VLESS a chave pública do REALITY (pbk).",
+ "scheme": "Isso não é um link VLESS. Ele precisa começar com vless://.",
+ "parameter": "A URI VLESS contém uma opção sem suporte no Donut.",
+ "malformed": "A URI VLESS é inválida."
+ },
+ "camoufoxRemoved": "O Camoufox não é mais compatível. Recrie este perfil com o Wayfern.",
+ "noE2ePasswordSet": "Nenhuma senha de criptografia de ponta a ponta foi definida. Defina uma antes de sincronizar dados criptografados."
},
"rail": {
"profiles": "Perfis",
@@ -2367,13 +2385,50 @@
"confirmBulkButton_many": "Continuar com {{count}} perfis",
"sitesRequired": "Adicione pelo menos um site.",
"addSitesFirst": "Adicione um site primeiro",
- "presetsMissing": "Predefinições de profundidade indisponíveis"
+ "presetsMissing": "Predefinições de profundidade indisponíveis",
+ "sourceOwn": "Minha própria lista",
+ "sourceCurated": "Selecionadas",
+ "sourceSaved": "Salvas",
+ "curatedEmpty": "Nenhuma lista selecionada está disponível no momento.",
+ "curatedNote": "Uma lista selecionada que mantemos atualizada. Cada perfil retira sua própria amostra dela, então não há dois perfis navegando pelo mesmo conjunto — é isso que impede a lista de virar uma assinatura. Os endereços ficam do nosso lado.",
+ "savedEmpty": "Nada salvo ainda. Digite uma lista em Minha própria lista e salve por lá.",
+ "savedNote": "Os sites são copiados para a programação quando você a salva, então editar uma lista depois não altera as programações existentes.",
+ "savedUnavailable": "Não foi possível carregar suas listas salvas.",
+ "saveAsList": "Salvar como lista",
+ "listNamePlaceholder": "Dê um nome a esta lista",
+ "listSaved": "Lista salva",
+ "listRenamed": "Lista renomeada",
+ "listDeleted": "Lista excluída",
+ "listRename": "Renomear",
+ "listDeleteConfirm": "Excluir?",
+ "templateMissing": "Essa lista salva não existe mais. Escolha outra.",
+ "templateNameTaken": "Você já tem uma lista com esse nome.",
+ "templateNameInvalid": "Dê à lista um nome de até {{max}} caracteres.",
+ "calendarLabel": "Quando executa",
+ "daysLabel": "Dias da semana",
+ "addSlot": "Adicionar um horário",
+ "slotsFull": "No máximo {{max}} horários de início.",
+ "removeSlot": "Remover este horário",
+ "pickListFirst": "Escolha uma lista primeiro",
+ "finishCalendarFirst": "Termine a programação primeiro",
+ "duplicateSlot": "Duas linhas têm os mesmos dias e o mesmo horário",
+ "listSites_one": "{{count}} site",
+ "listSites_other": "{{count}} sites",
+ "listSites_many": "{{count}} sites",
+ "summarySlots_one": "Executa {{count}} vez por semana, até {{minutes}} min por vez.",
+ "summarySlots_other": "Executa {{count}} vezes por semana, até {{minutes}} min por vez.",
+ "summarySlots_many": "Executa {{count}} vezes por semana, até {{minutes}} min por vez.",
+ "templateNameInvalidNoMax": "Esse nome não pode ser usado para uma lista salva."
},
"preset": {
"light": "Leve",
"balanced": "Padrão",
"deep": "Profunda"
},
+ "template": {
+ "lowIntentPurchaser": "Comprador de baixa intenção",
+ "lowIntentPurchaserHint": "Posiciona o perfil como um comprador sensível a preço: sites de comparação, cupons, cashback e revenda, chegando às lojas por agregadores em vez de diretamente."
+ },
"preflight": {
"ineligible_one": "{{count}} perfil não pode ser executado remotamente",
"ineligible_other": "{{count}} perfis não podem ser executados remotamente",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index 4560f16..12e22ef 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -92,7 +92,8 @@
"window": {
"minimize": "Свернуть",
"maximize": "Развернуть",
- "restore": "Восстановить"
+ "restore": "Восстановить",
+ "close": "Закрыть окно"
},
"commandPalette": {
"title": "Палитра команд",
@@ -469,7 +470,7 @@
"ssCipherRequired": "Для Shadowsocks требуется шифр и пароль",
"selectType": "Выберите тип прокси",
"saveFailed": "Не удалось сохранить прокси: {{error}}",
- "vlessType": "VLESS · Vision · REALITY",
+ "vlessType": "VLESS",
"vlessUri": "URI VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Требуются XTLS Vision и REALITY.",
@@ -1880,6 +1881,7 @@
"remoteNoCapacity": "Сейчас нет свободных удалённых машин. Попробуйте через несколько минут.",
"remoteNotEntitled": "Ваш тариф не включает удалённый запуск.",
"remoteInteractiveNotEntitled": "В вашем тарифе удалённые часы доступны только для Cookie Bot, но не для интерактивных удалённых сессий.",
+ "remoteRequiresRemoteExitNode": "Прокси этого профиля работает только на этом компьютере (например, 127.0.0.1 или адрес локальной сети). Удалённые сессии выполняются на наших хостах, поэтому нужен прокси с публичным адресом.",
"remoteSessionRefused": "Удалённая машина отклонила эту сессию.",
"remoteSessionNotFound": "Этой удалённой сессии больше не существует.",
"remoteSessionConflict": "Этот профиль уже открыт в другом месте.",
@@ -1900,6 +1902,7 @@
"cookieBotUnknownPlatform": "Для этого профиля не записана операционная система, поэтому подобрать машину невозможно.",
"cookieBotUnsupportedPlatform": "Cookie Bot не может запускать профили {{platform}}. Поддерживаются только профили Windows и macOS.",
"cookieBotRequiresExitNode": "Сначала назначьте прокси или VPN. Без них трафик пойдёт с адреса дата-центра, а это вредит репутации профиля.",
+ "cookieBotRequiresRemoteExitNode": "Прокси этого профиля работает только на этом компьютере (например, 127.0.0.1 или адрес локальной сети). Cookie Bot выполняется на наших хостах, поэтому нужен прокси с публичным адресом.",
"unknownCode": "Что-то пошло не так: {{code}}",
"cookieBotTouchFingerprintUnsupported": "Этот профиль выдаёт себя за сенсорное устройство, которым бот управлять не может. Используйте настольный отпечаток.",
"profileRunningRemotely": "Этот профиль запущен на удалённой машине. Сначала остановите удалённый сеанс.",
@@ -1910,7 +1913,22 @@
"fingerprintExitMismatch": "Выходной узел прокси не совпадает с отпечатком этого профиля.",
"launchConsentExpired": "Это подтверждение больше не действует. Запустите профиль ещё раз.",
"vpnWorkerStartFailed": "Не удалось запустить VPN-подключение: {{detail}}",
- "exitProbeFailed": "Не удалось связаться с выходным узлом прокси, чтобы определить его местоположение."
+ "exitProbeFailed": "Не удалось связаться с выходным узлом прокси, чтобы определить его местоположение.",
+ "vlessUnsupported": {
+ "security": "Этот сервер VLESS не использует REALITY. Donut поддерживает только VLESS с REALITY.",
+ "flow": "Этот сервер VLESS не использует поток XTLS Vision, который требуется Donut.",
+ "transport": "Donut поддерживает VLESS только поверх обычного TCP — этот сервер использует другой транспорт (например, WebSocket или gRPC).",
+ "encryption": "Этот сервер VLESS использует шифрование, которое Donut не поддерживает.",
+ "headerType": "Этот сервер VLESS использует обфускацию заголовков, которую Donut не поддерживает.",
+ "fingerprint": "Этот VLESS URI запрашивает отпечаток TLS, который Donut не поддерживает.",
+ "sni": "В VLESS URI отсутствует SNI (sni), необходимый для REALITY.",
+ "publicKey": "В VLESS URI отсутствует открытый ключ REALITY (pbk).",
+ "scheme": "Это не ссылка VLESS. Она должна начинаться с vless://.",
+ "parameter": "VLESS URI содержит параметр, который Donut не поддерживает.",
+ "malformed": "VLESS URI недействителен."
+ },
+ "camoufoxRemoved": "Camoufox больше не поддерживается. Создайте этот профиль заново с Wayfern.",
+ "noE2ePasswordSet": "Пароль сквозного шифрования не задан. Задайте его перед синхронизацией зашифрованных данных."
},
"rail": {
"profiles": "Профили",
@@ -2392,13 +2410,52 @@
"confirmBulkButton_many": "Продолжить с {{count}} профилями",
"sitesRequired": "Добавьте хотя бы один сайт.",
"addSitesFirst": "Сначала добавьте сайт",
- "presetsMissing": "Пресеты глубины недоступны"
+ "presetsMissing": "Пресеты глубины недоступны",
+ "sourceOwn": "Свой список",
+ "sourceCurated": "Подборки",
+ "sourceSaved": "Сохранённые",
+ "curatedEmpty": "Сейчас нет доступных подборок.",
+ "curatedNote": "Подборка, которую мы поддерживаем в актуальном состоянии. Каждый профиль берёт из неё свою выборку, поэтому два профиля не обходят один и тот же набор — именно это не даёт списку стать приметой. Адреса остаются на нашей стороне.",
+ "savedEmpty": "Пока ничего не сохранено. Введите список в разделе «Свой список» и сохраните его оттуда.",
+ "savedNote": "Сайты копируются в расписание при сохранении, поэтому правка списка позже не меняет уже созданные расписания.",
+ "savedUnavailable": "Не удалось загрузить сохранённые списки.",
+ "saveAsList": "Сохранить как список",
+ "listNamePlaceholder": "Название списка",
+ "listSaved": "Список сохранён",
+ "listRenamed": "Список переименован",
+ "listDeleted": "Список удалён",
+ "listRename": "Переименовать",
+ "listDeleteConfirm": "Удалить?",
+ "templateMissing": "Этого сохранённого списка больше нет. Выберите другой.",
+ "templateNameTaken": "Список с таким названием уже есть.",
+ "templateNameInvalid": "Название списка — не длиннее {{max}} символов.",
+ "calendarLabel": "Когда запускать",
+ "daysLabel": "Дни недели",
+ "addSlot": "Добавить время",
+ "slotsFull": "Не больше {{max}} времён запуска.",
+ "removeSlot": "Убрать это время",
+ "pickListFirst": "Сначала выберите список",
+ "finishCalendarFirst": "Сначала заполните расписание",
+ "duplicateSlot": "Две строки повторяют одни и те же дни и время",
+ "listSites_one": "{{count}} сайт",
+ "listSites_few": "{{count}} сайта",
+ "listSites_other": "{{count}} сайтов",
+ "listSites_many": "{{count}} сайтов",
+ "summarySlots_one": "Запускается {{count}} раз в неделю, не дольше {{minutes}} мин за раз.",
+ "summarySlots_few": "Запускается {{count}} раза в неделю, не дольше {{minutes}} мин за раз.",
+ "summarySlots_other": "Запускается {{count}} раз в неделю, не дольше {{minutes}} мин за раз.",
+ "summarySlots_many": "Запускается {{count}} раз в неделю, не дольше {{minutes}} мин за раз.",
+ "templateNameInvalidNoMax": "Это имя нельзя использовать для сохранённого списка."
},
"preset": {
"light": "Лёгкая",
"balanced": "Стандартная",
"deep": "Глубокая"
},
+ "template": {
+ "lowIntentPurchaser": "Покупатель с низким намерением",
+ "lowIntentPurchaserHint": "Позиционирует профиль как чувствительного к цене покупателя: сравнение цен, купоны, кэшбэк и перепродажа, а к магазинам — через агрегаторы, а не напрямую."
+ },
"preflight": {
"ineligible_one": "{{count}} профиль нельзя запустить удалённо",
"ineligible_few": "{{count}} профиля нельзя запустить удалённо",
diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json
index 53d6adb..8c19fb3 100644
--- a/src/i18n/locales/tr.json
+++ b/src/i18n/locales/tr.json
@@ -92,7 +92,8 @@
"window": {
"minimize": "Küçült",
"maximize": "Büyüt",
- "restore": "Geri Yükle"
+ "restore": "Geri Yükle",
+ "close": "Pencereyi kapat"
},
"commandPalette": {
"title": "Komut Paleti",
@@ -467,7 +468,7 @@
"ssCipherRequired": "Shadowsocks için şifreleme algoritması ve parola zorunludur",
"selectType": "Proxy türünü seçin",
"saveFailed": "Proxy kaydedilemedi: {{error}}",
- "vlessType": "VLESS · Vision · REALITY",
+ "vlessType": "VLESS",
"vlessUri": "VLESS URI'si",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "XTLS Vision ve REALITY gerektirir.",
@@ -1866,6 +1867,7 @@
"remoteNoCapacity": "Şu anda boş uzak makine yok. Birkaç dakika sonra tekrar deneyin.",
"remoteNotEntitled": "Planınız uzaktan çalıştırmayı içermiyor.",
"remoteInteractiveNotEntitled": "Planınızdaki uzak saatler yalnızca Cookie Bot için geçerlidir, elle kullanılan uzak oturumlar için değil.",
+ "remoteRequiresRemoteExitNode": "Bu profilin proxy'si yalnızca bu bilgisayarda çalışır (örneğin 127.0.0.1 veya yerel ağ adresi). Uzak oturumlar bizim sunucularımızda çalıştığı için genel bir adrese sahip bir proxy gerekir.",
"remoteSessionRefused": "Uzak makine bu oturumu reddetti.",
"remoteSessionNotFound": "Bu uzak oturum artık mevcut değil.",
"remoteSessionConflict": "Bu profil başka bir yerde zaten açık.",
@@ -1886,6 +1888,7 @@
"cookieBotUnknownPlatform": "Bu profilde kayıtlı bir işletim sistemi yok, bu yüzden bir makineyle eşleştirilemiyor.",
"cookieBotUnsupportedPlatform": "Cookie Bot, {{platform}} profillerini çalıştıramaz. Yalnızca Windows ve macOS profilleri desteklenir.",
"cookieBotRequiresExitNode": "Önce bir proxy veya VPN ekleyin. Aksi hâlde çalışma bir veri merkezi adresinden çıkar ve bu, profilin kimliğine zarar verir.",
+ "cookieBotRequiresRemoteExitNode": "Bu profilin proxy'si yalnızca bu bilgisayarda çalışır (örneğin 127.0.0.1 veya yerel ağ adresi). Cookie Bot bizim sunucularımızda çalıştığı için genel bir adrese sahip bir proxy gerekir.",
"unknownCode": "Bir sorun oluştu: {{code}}",
"cookieBotTouchFingerprintUnsupported": "Bu profil dokunmatik bir cihaz olduğunu bildiriyor ve bot bunu süremez. Masaüstü parmak izi kullanın.",
"profileRunningRemotely": "Bu profil uzak bir makinede çalışıyor. Önce uzak oturumu durdurun.",
@@ -1896,7 +1899,22 @@
"fingerprintExitMismatch": "Proxy çıkış düğümü bu profilin parmak iziyle eşleşmiyor.",
"launchConsentExpired": "Bu onay artık geçerli değil. Yeniden başlatmayı deneyin.",
"vpnWorkerStartFailed": "VPN bağlantısı başlatılamadı: {{detail}}",
- "exitProbeFailed": "Konumunu denetlemek için proxy çıkış düğümüne ulaşılamadı."
+ "exitProbeFailed": "Konumunu denetlemek için proxy çıkış düğümüne ulaşılamadı.",
+ "vlessUnsupported": {
+ "security": "Bu VLESS sunucusu REALITY kullanmıyor. Donut yalnızca REALITY ile VLESS'i destekler.",
+ "flow": "Bu VLESS sunucusu, Donut'ın gerektirdiği XTLS Vision akışını kullanmıyor.",
+ "transport": "Donut yalnızca düz TCP üzerinden VLESS'i destekler — bu sunucu farklı bir taşıma (WebSocket veya gRPC gibi) kullanıyor.",
+ "encryption": "Bu VLESS sunucusu, Donut'ın desteklemediği bir şifreleme kullanıyor.",
+ "headerType": "Bu VLESS sunucusu, Donut'ın desteklemediği bir başlık gizlemesi kullanıyor.",
+ "fingerprint": "Bu VLESS URI'si, Donut'ın desteklemediği bir TLS parmak izi istiyor.",
+ "sni": "VLESS URI'sinde REALITY için gereken SNI (sni) eksik.",
+ "publicKey": "VLESS URI'sinde REALITY genel anahtarı (pbk) eksik.",
+ "scheme": "Bu bir VLESS bağlantısı değil. vless:// ile başlamalı.",
+ "parameter": "VLESS URI'si, Donut'ın desteklemediği bir seçenek içeriyor.",
+ "malformed": "VLESS URI'si geçersiz."
+ },
+ "camoufoxRemoved": "Camoufox artık desteklenmiyor. Bu profili Wayfern ile yeniden oluşturun.",
+ "noE2ePasswordSet": "Uçtan uca şifreleme parolası ayarlanmamış. Şifreli veriyi eşitlemeden önce bir parola belirleyin."
},
"rail": {
"profiles": "Profiller",
@@ -2342,13 +2360,48 @@
"confirmBulkButton_other": "{{count}} profille devam et",
"sitesRequired": "En az bir site ekleyin.",
"addSitesFirst": "Önce bir site ekleyin",
- "presetsMissing": "Derinlik ön ayarları kullanılamıyor"
+ "presetsMissing": "Derinlik ön ayarları kullanılamıyor",
+ "sourceOwn": "Kendi listem",
+ "sourceCurated": "Seçilmiş",
+ "sourceSaved": "Kayıtlı",
+ "curatedEmpty": "Şu anda kullanılabilir seçilmiş liste yok.",
+ "curatedNote": "Güncel tuttuğumuz seçilmiş bir liste. Her profil kendi örneklemini alır, bu yüzden iki profil aynı kümeyi gezmez — listenin kendisinin bir imzaya dönüşmesini engelleyen şey budur. Adresler bizim tarafımızda kalır.",
+ "savedEmpty": "Henüz kaydedilmiş bir şey yok. Kendi listem sekmesinde bir liste yazıp oradan kaydedin.",
+ "savedNote": "Siteler programı kaydettiğinizde programa kopyalanır; listeyi sonradan düzenlemeniz mevcut programları etkilemez.",
+ "savedUnavailable": "Kayıtlı listeleriniz yüklenemedi.",
+ "saveAsList": "Liste olarak kaydet",
+ "listNamePlaceholder": "Bu listeye bir ad verin",
+ "listSaved": "Liste kaydedildi",
+ "listRenamed": "Liste yeniden adlandırıldı",
+ "listDeleted": "Liste silindi",
+ "listRename": "Yeniden adlandır",
+ "listDeleteConfirm": "Silinsin mi?",
+ "templateMissing": "O kayıtlı liste artık yok. Başka birini seçin.",
+ "templateNameTaken": "Bu ada sahip bir listeniz zaten var.",
+ "templateNameInvalid": "Listeye en fazla {{max}} karakterlik bir ad verin.",
+ "calendarLabel": "Ne zaman çalışır",
+ "daysLabel": "Haftanın günleri",
+ "addSlot": "Saat ekle",
+ "slotsFull": "En fazla {{max}} başlangıç saati.",
+ "removeSlot": "Bu saati kaldır",
+ "pickListFirst": "Önce bir liste seçin",
+ "finishCalendarFirst": "Önce programı tamamlayın",
+ "duplicateSlot": "İki satırın günleri ve saati aynı",
+ "listSites_one": "{{count}} site",
+ "listSites_other": "{{count}} site",
+ "summarySlots_one": "Haftada {{count}} kez, her seferinde en fazla {{minutes}} dk çalışır.",
+ "summarySlots_other": "Haftada {{count}} kez, her seferinde en fazla {{minutes}} dk çalışır.",
+ "templateNameInvalidNoMax": "Bu ad kayıtlı bir liste için kullanılamaz."
},
"preset": {
"light": "Hafif",
"balanced": "Standart",
"deep": "Derin"
},
+ "template": {
+ "lowIntentPurchaser": "Düşük niyetli alıcı",
+ "lowIntentPurchaserHint": "Profili fiyata duyarlı bir alıcı olarak konumlar: karşılaştırma, kupon, nakit iade ve ikinci el siteleri; mağazalara doğrudan değil toplayıcılar üzerinden ulaşır."
+ },
"preflight": {
"ineligible_one": "{{count}} profil uzaktan çalıştırılamıyor",
"ineligible_other": "{{count}} profil uzaktan çalıştırılamıyor",
diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json
index 87e722c..a3e2e2b 100644
--- a/src/i18n/locales/vi.json
+++ b/src/i18n/locales/vi.json
@@ -92,7 +92,8 @@
"window": {
"minimize": "Thu nhỏ",
"maximize": "Phóng to",
- "restore": "Khôi phục"
+ "restore": "Khôi phục",
+ "close": "Đóng cửa sổ"
},
"commandPalette": {
"title": "Bảng lệnh",
@@ -467,7 +468,7 @@
"ssCipherRequired": "Cipher và mật khẩu là bắt buộc cho Shadowsocks",
"selectType": "Chọn loại proxy",
"saveFailed": "Lưu proxy thất bại: {{error}}",
- "vlessType": "VLESS · Vision · REALITY",
+ "vlessType": "VLESS",
"vlessUri": "URI VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Yêu cầu XTLS Vision và REALITY.",
@@ -1866,6 +1867,7 @@
"remoteNoCapacity": "Hiện không có máy từ xa nào rảnh. Hãy thử lại sau vài phút.",
"remoteNotEntitled": "Gói của bạn không bao gồm chạy từ xa.",
"remoteInteractiveNotEntitled": "Gói của bạn chỉ bao gồm giờ từ xa cho Cookie Bot, không dùng cho phiên từ xa thao tác trực tiếp.",
+ "remoteRequiresRemoteExitNode": "Proxy của hồ sơ này chỉ hoạt động trên máy tính này (ví dụ 127.0.0.1 hoặc địa chỉ mạng nội bộ). Phiên từ xa chạy trên máy chủ của chúng tôi nên cần proxy có địa chỉ công khai.",
"remoteSessionRefused": "Máy từ xa đã từ chối phiên này.",
"remoteSessionNotFound": "Phiên từ xa đó không còn tồn tại.",
"remoteSessionConflict": "Hồ sơ này đang được mở ở nơi khác.",
@@ -1886,6 +1888,7 @@
"cookieBotUnknownPlatform": "Hồ sơ này chưa ghi nhận hệ điều hành nên không thể ghép với máy nào.",
"cookieBotUnsupportedPlatform": "Cookie Bot không chạy được hồ sơ {{platform}}. Chỉ hỗ trợ hồ sơ Windows và macOS.",
"cookieBotRequiresExitNode": "Hãy gán proxy hoặc VPN trước. Nếu không, lần chạy sẽ đi ra từ địa chỉ trung tâm dữ liệu, gây hại cho danh tính hồ sơ.",
+ "cookieBotRequiresRemoteExitNode": "Proxy của hồ sơ này chỉ hoạt động trên máy tính này (ví dụ 127.0.0.1 hoặc địa chỉ mạng nội bộ). Cookie Bot chạy trên máy chủ của chúng tôi nên cần proxy có địa chỉ công khai.",
"unknownCode": "Đã xảy ra lỗi: {{code}}",
"cookieBotTouchFingerprintUnsupported": "Hồ sơ này khai báo là thiết bị cảm ứng, bot không điều khiển được. Hãy dùng vân tay máy tính để bàn.",
"profileRunningRemotely": "Hồ sơ này đang chạy trên máy từ xa. Hãy dừng phiên từ xa trước.",
@@ -1896,7 +1899,22 @@
"fingerprintExitMismatch": "Nút thoát của proxy không khớp với dấu vân tay của hồ sơ này.",
"launchConsentExpired": "Xác nhận đó không còn hiệu lực. Hãy thử khởi chạy lại.",
"vpnWorkerStartFailed": "Không thể khởi động kết nối VPN: {{detail}}",
- "exitProbeFailed": "Không thể kết nối tới nút thoát của proxy để kiểm tra vị trí."
+ "exitProbeFailed": "Không thể kết nối tới nút thoát của proxy để kiểm tra vị trí.",
+ "vlessUnsupported": {
+ "security": "Máy chủ VLESS này không dùng REALITY. Donut chỉ hỗ trợ VLESS kèm REALITY.",
+ "flow": "Máy chủ VLESS này không dùng luồng XTLS Vision mà Donut yêu cầu.",
+ "transport": "Donut chỉ hỗ trợ VLESS trên TCP thuần — máy chủ này dùng phương thức truyền khác (như WebSocket hoặc gRPC).",
+ "encryption": "Máy chủ VLESS này dùng thiết lập mã hóa mà Donut không hỗ trợ.",
+ "headerType": "Máy chủ VLESS này dùng cách che giấu tiêu đề mà Donut không hỗ trợ.",
+ "fingerprint": "URI VLESS này yêu cầu một dấu vân tay TLS mà Donut không hỗ trợ.",
+ "sni": "URI VLESS thiếu SNI (sni) cần cho REALITY.",
+ "publicKey": "URI VLESS thiếu khóa công khai REALITY (pbk).",
+ "scheme": "Đây không phải liên kết VLESS. Nó phải bắt đầu bằng vless://.",
+ "parameter": "URI VLESS chứa một tùy chọn mà Donut không hỗ trợ.",
+ "malformed": "URI VLESS không hợp lệ."
+ },
+ "camoufoxRemoved": "Camoufox không còn được hỗ trợ. Hãy tạo lại hồ sơ này bằng Wayfern.",
+ "noE2ePasswordSet": "Chưa đặt mật khẩu mã hóa đầu cuối. Hãy đặt trước khi đồng bộ dữ liệu đã mã hóa."
},
"rail": {
"profiles": "Profile",
@@ -2342,13 +2360,48 @@
"confirmBulkButton_other": "Tiếp tục với {{count}} hồ sơ",
"sitesRequired": "Hãy thêm ít nhất một trang.",
"addSitesFirst": "Hãy thêm một trang trước",
- "presetsMissing": "Không có cài đặt sẵn về độ sâu"
+ "presetsMissing": "Không có cài đặt sẵn về độ sâu",
+ "sourceOwn": "Danh sách của tôi",
+ "sourceCurated": "Tuyển chọn",
+ "sourceSaved": "Đã lưu",
+ "curatedEmpty": "Hiện không có danh sách tuyển chọn nào.",
+ "curatedNote": "Danh sách tuyển chọn do chúng tôi cập nhật. Mỗi hồ sơ lấy một mẫu riêng từ đó, nên không hồ sơ nào duyệt cùng một tập trang — chính điều này khiến danh sách không trở thành dấu hiệu nhận dạng. Các địa chỉ vẫn nằm ở phía chúng tôi.",
+ "savedEmpty": "Chưa lưu gì cả. Hãy nhập danh sách ở mục Danh sách của tôi rồi lưu từ đó.",
+ "savedNote": "Các trang được sao chép vào lịch khi bạn lưu, nên sửa danh sách sau này không ảnh hưởng tới các lịch đã có.",
+ "savedUnavailable": "Không tải được các danh sách đã lưu của bạn.",
+ "saveAsList": "Lưu thành danh sách",
+ "listNamePlaceholder": "Đặt tên cho danh sách",
+ "listSaved": "Đã lưu danh sách",
+ "listRenamed": "Đã đổi tên danh sách",
+ "listDeleted": "Đã xóa danh sách",
+ "listRename": "Đổi tên",
+ "listDeleteConfirm": "Xóa?",
+ "templateMissing": "Danh sách đã lưu đó không còn nữa. Hãy chọn danh sách khác.",
+ "templateNameTaken": "Bạn đã có một danh sách trùng tên.",
+ "templateNameInvalid": "Đặt tên danh sách tối đa {{max}} ký tự.",
+ "calendarLabel": "Thời điểm chạy",
+ "daysLabel": "Các ngày trong tuần",
+ "addSlot": "Thêm một giờ",
+ "slotsFull": "Tối đa {{max}} giờ bắt đầu.",
+ "removeSlot": "Bỏ giờ này",
+ "pickListFirst": "Chọn một danh sách trước",
+ "finishCalendarFirst": "Hoàn tất lịch trước",
+ "duplicateSlot": "Hai hàng có cùng ngày và giờ",
+ "listSites_one": "{{count}} trang",
+ "listSites_other": "{{count}} trang",
+ "summarySlots_one": "Chạy {{count}} lần mỗi tuần, tối đa {{minutes}} phút mỗi lần.",
+ "summarySlots_other": "Chạy {{count}} lần mỗi tuần, tối đa {{minutes}} phút mỗi lần.",
+ "templateNameInvalidNoMax": "Không thể dùng tên đó cho danh sách đã lưu."
},
"preset": {
"light": "Nhẹ",
"balanced": "Tiêu chuẩn",
"deep": "Sâu"
},
+ "template": {
+ "lowIntentPurchaser": "Người mua ít ý định",
+ "lowIntentPurchaserHint": "Định vị hồ sơ như một người mua nhạy cảm về giá: các trang so sánh, mã giảm giá, hoàn tiền và mua bán lại, đến với người bán qua trang tổng hợp thay vì trực tiếp."
+ },
"preflight": {
"ineligible_one": "{{count}} hồ sơ không chạy từ xa được",
"ineligible_other": "{{count}} hồ sơ không chạy từ xa được",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 5862656..3bc4370 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -92,7 +92,8 @@
"window": {
"minimize": "最小化",
"maximize": "最大化",
- "restore": "还原"
+ "restore": "还原",
+ "close": "关闭窗口"
},
"commandPalette": {
"title": "命令面板",
@@ -467,7 +468,7 @@
"ssCipherRequired": "Shadowsocks 需要密码学和密码",
"selectType": "选择代理类型",
"saveFailed": "保存代理失败: {{error}}",
- "vlessType": "VLESS · Vision · REALITY",
+ "vlessType": "VLESS",
"vlessUri": "VLESS URI",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "需要 XTLS Vision 和 REALITY。",
@@ -1866,6 +1867,7 @@
"remoteNoCapacity": "当前没有空闲的远程机器。请几分钟后再试。",
"remoteNotEntitled": "你的套餐不包含远程运行。",
"remoteInteractiveNotEntitled": "您的套餐中的远程时长仅供 Cookie Bot 使用,不能用于手动远程会话。",
+ "remoteRequiresRemoteExitNode": "此配置文件的代理仅在本机可用(例如 127.0.0.1 或局域网地址)。远程会话在我们的主机上运行,因此需要具有公网地址的代理。",
"remoteSessionRefused": "远程机器拒绝了此会话。",
"remoteSessionNotFound": "该远程会话已不存在。",
"remoteSessionConflict": "此配置文件已在别处打开。",
@@ -1886,6 +1888,7 @@
"cookieBotUnknownPlatform": "此配置文件没有记录操作系统,无法匹配到机器。",
"cookieBotUnsupportedPlatform": "Cookie Bot 无法运行 {{platform}} 配置文件。仅支持 Windows 和 macOS 配置文件。",
"cookieBotRequiresExitNode": "请先绑定代理或 VPN。否则运行会从数据中心地址发出,损害配置文件的身份。",
+ "cookieBotRequiresRemoteExitNode": "此配置文件的代理仅在本机可用(例如 127.0.0.1 或局域网地址)。Cookie Bot 在我们的主机上运行,因此需要具有公网地址的代理。",
"unknownCode": "出现问题: {{code}}",
"cookieBotTouchFingerprintUnsupported": "该配置文件声称是触摸设备,机器人无法操作。请使用桌面端指纹。",
"profileRunningRemotely": "该配置文件正在远程计算机上运行。请先停止远程会话。",
@@ -1896,7 +1899,22 @@
"fingerprintExitMismatch": "代理出口节点与此配置文件的指纹不匹配。",
"launchConsentExpired": "该确认已失效。请重新启动。",
"vpnWorkerStartFailed": "无法启动 VPN 连接:{{detail}}",
- "exitProbeFailed": "无法连接代理出口节点以检查其位置。"
+ "exitProbeFailed": "无法连接代理出口节点以检查其位置。",
+ "vlessUnsupported": {
+ "security": "此 VLESS 服务器未使用 REALITY。Donut 仅支持搭配 REALITY 的 VLESS。",
+ "flow": "此 VLESS 服务器未使用 Donut 所需的 XTLS Vision 流控。",
+ "transport": "Donut 仅支持基于普通 TCP 的 VLESS —— 此服务器使用了其他传输方式(如 WebSocket 或 gRPC)。",
+ "encryption": "此 VLESS 服务器使用了 Donut 不支持的加密设置。",
+ "headerType": "此 VLESS 服务器使用了 Donut 不支持的头部混淆。",
+ "fingerprint": "此 VLESS URI 请求了 Donut 不支持的 TLS 指纹。",
+ "sni": "VLESS URI 缺少 REALITY 所需的 SNI(sni)。",
+ "publicKey": "VLESS URI 缺少 REALITY 公钥(pbk)。",
+ "scheme": "这不是 VLESS 链接,必须以 vless:// 开头。",
+ "parameter": "VLESS URI 含有 Donut 不支持的选项。",
+ "malformed": "VLESS URI 无效。"
+ },
+ "camoufoxRemoved": "Camoufox 已不再受支持。请使用 Wayfern 重新创建此配置文件。",
+ "noE2ePasswordSet": "尚未设置端到端加密密码。请先设置后再同步加密数据。"
},
"rail": {
"profiles": "配置文件",
@@ -2342,13 +2360,48 @@
"confirmBulkButton_other": "继续({{count}} 个配置文件)",
"sitesRequired": "请至少添加一个网站。",
"addSitesFirst": "请先添加网站",
- "presetsMissing": "深度预设不可用"
+ "presetsMissing": "深度预设不可用",
+ "sourceOwn": "我的列表",
+ "sourceCurated": "精选",
+ "sourceSaved": "已保存",
+ "curatedEmpty": "目前没有可用的精选列表。",
+ "curatedNote": "由我们持续维护的精选列表。每个配置文件都会从中抽取各自的样本,因此不会有两个配置文件浏览同一组网站——这正是让列表本身不会变成特征的原因。地址只保留在我们这边。",
+ "savedEmpty": "还没有保存任何内容。请在“我的列表”中输入列表,然后从那里保存。",
+ "savedNote": "保存计划时会把网站复制到计划中,因此以后编辑列表不会影响已有的计划。",
+ "savedUnavailable": "无法加载你保存的列表。",
+ "saveAsList": "保存为列表",
+ "listNamePlaceholder": "为该列表命名",
+ "listSaved": "已保存列表",
+ "listRenamed": "已重命名列表",
+ "listDeleted": "已删除列表",
+ "listRename": "重命名",
+ "listDeleteConfirm": "删除?",
+ "templateMissing": "该保存的列表已不存在。请另选一个。",
+ "templateNameTaken": "你已经有同名的列表。",
+ "templateNameInvalid": "列表名称不超过 {{max}} 个字符。",
+ "calendarLabel": "运行时间",
+ "daysLabel": "星期",
+ "addSlot": "添加时间",
+ "slotsFull": "最多 {{max}} 个开始时间。",
+ "removeSlot": "移除此时间",
+ "pickListFirst": "请先选择列表",
+ "finishCalendarFirst": "请先完成计划",
+ "duplicateSlot": "有两行的星期和时间相同",
+ "listSites_one": "{{count}} 个网站",
+ "listSites_other": "{{count}} 个网站",
+ "summarySlots_one": "每周运行 {{count}} 次,每次最多 {{minutes}} 分钟。",
+ "summarySlots_other": "每周运行 {{count}} 次,每次最多 {{minutes}} 分钟。",
+ "templateNameInvalidNoMax": "该名称不能用于已保存的列表。"
},
"preset": {
"light": "轻度",
"balanced": "标准",
"deep": "深度"
},
+ "template": {
+ "lowIntentPurchaser": "低购买意向买家",
+ "lowIntentPurchaserHint": "把配置文件定位为对价格敏感的买家:比价、优惠券、返现和二手转卖网站,并通过聚合站点而非直接访问零售商。"
+ },
"preflight": {
"ineligible_one": "{{count}} 个配置文件无法远程运行",
"ineligible_other": "{{count}} 个配置文件无法远程运行",
diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts
index 569ddc8..e579219 100644
--- a/src/lib/backend-errors.ts
+++ b/src/lib/backend-errors.ts
@@ -74,6 +74,11 @@ export type BackendErrorCode =
| "REMOTE_NO_CAPACITY"
| "REMOTE_NOT_ENTITLED"
| "REMOTE_INTERACTIVE_NOT_ENTITLED"
+ // The profile's exit only resolves on this computer, so a leased host cannot
+ // use it. Its own code rather than the Cookie Bot's twin: the two refusals
+ // name different features, and a user told their "Cookie Bot" needs a public
+ // proxy while they were opening a browser by hand cannot act on that.
+ | "REMOTE_REQUIRES_REMOTE_EXIT_NODE"
| "REMOTE_SESSION_REFUSED"
| "REMOTE_SESSION_NOT_FOUND"
| "REMOTE_SESSION_CONFLICT"
@@ -99,6 +104,11 @@ export type BackendErrorCode =
| "COOKIE_BOT_UNKNOWN_PLATFORM"
| "COOKIE_BOT_UNSUPPORTED_PLATFORM"
| "COOKIE_BOT_REQUIRES_EXIT_NODE"
+ // The profile HAS an exit, but only this machine can reach it (127.0.0.1, a
+ // LAN address, a `.local` name). Its own code because the fix is different:
+ // "attach a proxy" is unactionable advice for someone whose proxy is plainly
+ // attached.
+ | "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE"
// The server's own names for two refusals it throws from `putSchedule`,
// `updateProfileState` and `runNow`. `COOKIE_BOT_REQUIRES_PROXY` is the
// server-side twin of the local `COOKIE_BOT_REQUIRES_EXIT_NODE` precondition;
@@ -110,6 +120,8 @@ export type BackendErrorCode =
| "LAUNCH_CONSENT_EXPIRED"
| "VPN_WORKER_START_FAILED"
| "EXIT_PROBE_FAILED"
+ | "CAMOUFOX_REMOVED"
+ | "NO_E2E_PASSWORD_SET"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -289,8 +301,29 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.mcpAgentRemoveFailed", {
detail: parsed.params?.detail ?? "",
});
- case "VLESS_CONFIG_INVALID":
+ // Donut supports exactly one VLESS shape (REALITY + XTLS Vision over TCP),
+ // so most rejections mean "your server is a kind we do not support", not
+ // "you mistyped". Name the unsupported part instead of implying a typo.
+ case "VLESS_CONFIG_INVALID": {
+ const reason = parsed.params?.reason;
+ const known = [
+ "security",
+ "flow",
+ "transport",
+ "encryption",
+ "headerType",
+ "fingerprint",
+ "sni",
+ "publicKey",
+ "scheme",
+ "parameter",
+ "malformed",
+ ];
+ if (reason && known.includes(reason)) {
+ return t(`backendErrors.vlessUnsupported.${reason}`);
+ }
return t("backendErrors.vlessConfigInvalid");
+ }
case "XRAY_UNAVAILABLE":
return t("backendErrors.xrayUnavailable");
case "XRAY_UNSUPPORTED_OS":
@@ -317,6 +350,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
// runs every night is the confusing case this code exists to avoid.
case "REMOTE_INTERACTIVE_NOT_ENTITLED":
return t("backendErrors.remoteInteractiveNotEntitled");
+ case "REMOTE_REQUIRES_REMOTE_EXIT_NODE":
+ return t("backendErrors.remoteRequiresRemoteExitNode");
case "REMOTE_SESSION_REFUSED":
return t("backendErrors.remoteSessionRefused");
case "REMOTE_SESSION_NOT_FOUND":
@@ -389,6 +424,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
// resolve to the one sentence a user can act on.
case "COOKIE_BOT_REQUIRES_PROXY":
return t("backendErrors.cookieBotRequiresExitNode");
+ case "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE":
+ return t("backendErrors.cookieBotRequiresRemoteExitNode");
case "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED":
return t("backendErrors.cookieBotTouchFingerprintUnsupported");
// The launch gate's block. The dialog renders the mismatch detail from
@@ -404,6 +441,10 @@ export function translateBackendError(t: TFunction, err: unknown): string {
});
case "EXIT_PROBE_FAILED":
return t("backendErrors.exitProbeFailed");
+ case "CAMOUFOX_REMOVED":
+ return t("backendErrors.camoufoxRemoved");
+ case "NO_E2E_PASSWORD_SET":
+ return t("backendErrors.noE2ePasswordSet");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
diff --git a/src/lib/cookie-bot.ts b/src/lib/cookie-bot.ts
index 1050590..d49242d 100644
--- a/src/lib/cookie-bot.ts
+++ b/src/lib/cookie-bot.ts
@@ -13,24 +13,70 @@ import { invoke } from "@tauri-apps/api/core";
/** Bit 0 = Monday, bit 6 = Sunday. */
export const COOKIE_BOT_DAY_BITS = [1, 2, 4, 8, 16, 32, 64] as const;
+/**
+ * What marks a `template_id` as one of the USER's own rather than a curated one.
+ *
+ * The two kinds share one field and behave in opposite ways — a curated
+ * template's URLs are server-owned and expanded per profile at dispatch, a
+ * user's are copied onto the enrolment when it is saved. An id read as the wrong
+ * kind is a schedule that browses the wrong list, so every question about which
+ * kind an id is goes through the helper below rather than a `startsWith` at the
+ * call site.
+ */
+export const COOKIE_BOT_USER_TEMPLATE_PREFIX = "user:";
+
+export function isUserTemplateId(id: string | null | undefined): boolean {
+ return (
+ typeof id === "string" && id.startsWith(COOKIE_BOT_USER_TEMPLATE_PREFIX)
+ );
+}
+
/** Hosts the fleet can lease. Linux is refused at enrolment. */
export type CookieBotPlatform = "windows" | "macos";
/** `mine` shows the caller's enrolments, `team` the whole team's. */
export type CookieBotScope = "mine" | "team";
+/** One time-of-day an enrolment fires, on a set of local weekdays. */
+export interface CookieBotSlot {
+ /** Bitmask of local weekdays, bit 0 = Monday. At least one bit set. */
+ days_mask: number;
+ /** Minutes past local midnight, in the schedule's timezone. */
+ run_at_minute: number;
+}
+
export interface CookieBotSchedule {
profile_id: string;
profile_name: string;
platform: string;
enabled: boolean;
- /** 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.
+ */
run_at_minute: number;
- /** Bitmask of local weekdays, bit 0 = Monday. */
+ /** The first slot's weekdays, bit 0 = Monday. See `run_at_minute`. */
days_mask: number;
+ /**
+ * Every time-of-day this enrolment fires.
+ *
+ * Optional because a server that predates multi-slot scheduling sends only
+ * the mirrored pair above. Read it through `scheduleSlots()` rather than
+ * directly, so the fallback happens in one place instead of at each renderer
+ * — an empty list here means "this server did not say", never "never fires".
+ */
+ slots?: CookieBotSlot[];
timezone: string;
/** Opaque server-issued preset id. */
preset: string;
+ /**
+ * The template the site list came from, or null for the user's own list.
+ *
+ * A built-in id means `sites` is EMPTY on purpose: those URLs are curated
+ * server-side and deliberately never sent to a client. A `user:` id is
+ * provenance — the sites were copied onto the enrolment and are present.
+ */
+ template_id?: string | null;
max_minutes: number;
sites: string[];
jitter_seconds: number;
@@ -70,10 +116,23 @@ export interface CookieBotScheduleInput {
profile_name: string;
platform: CookieBotPlatform;
enabled: boolean;
+ /** Mirror of `slots[0]`, for a server that predates multi-slot scheduling. */
run_at_minute: number;
+ /** Mirror of `slots[0]`. See `run_at_minute`. */
days_mask: number;
+ /**
+ * The whole calendar. Omit it — never send an empty array — for "one slot,
+ * from the pair above": the server refuses an empty list, because a schedule
+ * that fires at no time is a mistake rather than a way to pause one.
+ */
+ slots?: CookieBotSlot[];
timezone: string;
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.
+ */
+ template_id?: string;
max_minutes: number;
sites: string[];
jitter_seconds?: number;
@@ -162,9 +221,70 @@ export interface CookieBotPreset {
description?: string | null;
}
+/**
+ * A curated browsing template: a named answer to "what is this profile for",
+ * picked INSTEAD of typing a site list.
+ *
+ * Carries a count and never the URLs. That is the product working as designed —
+ * the pool is curated server-side and each profile draws its own sample from it,
+ * so the template never becomes one recognisable fleet-wide set of visits. Any
+ * copy describing this must say so as the feature it is.
+ */
+export interface CookieBotTemplate {
+ id: string;
+ /** How many sites this template browses. Not which. */
+ site_count: number;
+ /** Server-supplied English fallbacks, for a template newer than this build. */
+ name?: string | null;
+ description?: string | null;
+}
+
+/**
+ * The server's own form bounds, when it publishes them.
+ *
+ * Every field is optional: a deployment that predates this object sends none of
+ * them, and treating a missing bound as `0` would refuse every value the form
+ * can produce. `SCHEDULE_BOUNDS` in `cookie-bot-limits.ts` is the fallback.
+ */
+export interface CookieBotLimits {
+ min_minutes?: number | null;
+ max_minutes?: number | null;
+ min_sites?: number | null;
+ max_sites?: number | null;
+ /** Most entries a calendar may carry. */
+ max_slots?: number | null;
+ /** Longest name a saved site list may be given. */
+ max_template_name_length?: number | null;
+}
+
export interface CookieBotPresetList {
presets: CookieBotPreset[];
default_preset?: string | null;
+ /**
+ * The curated templates on offer. Served with the presets so one added
+ * server-side becomes selectable without a desktop release.
+ */
+ templates?: CookieBotTemplate[];
+ limits?: CookieBotLimits | null;
+}
+
+/**
+ * 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 editing a list later does not silently change what an existing
+ * schedule browses.
+ */
+export interface CookieBotUserTemplate {
+ /**
+ * Already prefixed `user:` — the value `template_id` takes verbatim.
+ * Nothing on this side assembles that convention.
+ */
+ id: string;
+ name: string;
+ sites: string[];
+ updated_at?: string | null;
}
export interface RemoteHoursBreakdown {
@@ -327,6 +447,47 @@ export function getCookieBotPresets(): Promise {
return invoke("get_cookie_bot_presets");
}
+/** Every site list this user has saved, most recently edited first. */
+export function getCookieBotUserTemplates(): Promise {
+ return invoke("get_cookie_bot_user_templates");
+}
+
+/** Save the current site list under a name. */
+export function createCookieBotUserTemplate(
+ name: string,
+ sites: string[],
+): Promise {
+ return invoke("create_cookie_bot_user_template", {
+ name,
+ sites,
+ });
+}
+
+/**
+ * Rename a saved list, replace its sites, or both.
+ *
+ * Send only what changed. A rename that also carried the site list would
+ * silently revert an edit made to it from another device in between.
+ */
+export function updateCookieBotUserTemplate(
+ id: string,
+ changes: { name?: string; sites?: string[] },
+): Promise {
+ return invoke("update_cookie_bot_user_template", {
+ id,
+ name: changes.name,
+ sites: changes.sites,
+ });
+}
+
+/**
+ * Delete a saved list. `false` means there was nothing left to delete, which is
+ * a success — enrolments that used it keep the sites they copied either way.
+ */
+export function deleteCookieBotUserTemplate(id: string): Promise {
+ return invoke("delete_cookie_bot_user_template", { id });
+}
+
export function getRemoteHoursQuota(): Promise {
return invoke("get_remote_hours_quota");
}
diff --git a/src/lib/window-decorations.test.mjs b/src/lib/window-decorations.test.mjs
new file mode 100644
index 0000000..47840dd
--- /dev/null
+++ b/src/lib/window-decorations.test.mjs
@@ -0,0 +1,116 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ DEFAULT_DECORATION_LAYOUT,
+ parseDecorationLayout,
+} from "./window-decorations.ts";
+
+/**
+ * The app draws its own titlebar on Linux, so it owns the window controls —
+ * and where they go is a desktop-wide user preference. This parser is the only
+ * thing standing between that preference and the buttons we render, and only
+ * GNOME can be exercised on the machine this was written on, so KDE's real
+ * layout strings are pinned here instead.
+ */
+
+test("GNOME's default puts every control on the right", () => {
+ assert.deepEqual(parseDecorationLayout(":minimize,maximize,close"), {
+ left: [],
+ right: ["minimize", "maximize", "close"],
+ });
+});
+
+test("a left-hand layout is honored", () => {
+ // GNOME users who prefer macOS ordering set exactly this.
+ assert.deepEqual(parseDecorationLayout("close,minimize,maximize:"), {
+ left: ["close", "minimize", "maximize"],
+ right: [],
+ });
+});
+
+test("controls can be split across both sides", () => {
+ assert.deepEqual(parseDecorationLayout("close:minimize,maximize"), {
+ left: ["close"],
+ right: ["minimize", "maximize"],
+ });
+});
+
+test("GTK's own default drops the appmenu it cannot draw", () => {
+ assert.deepEqual(parseDecorationLayout("appmenu:close"), {
+ left: [],
+ right: ["close"],
+ });
+});
+
+test("non-button tokens are ignored rather than rendered", () => {
+ // `icon`, `menu`, `appmenu` and `spacer` are all legal GTK tokens for things
+ // this titlebar does not draw.
+ assert.deepEqual(parseDecorationLayout("icon,menu:spacer,close"), {
+ left: [],
+ right: ["close"],
+ });
+});
+
+test("KDE's extra decoration buttons are ignored", () => {
+ // KWin offers buttons GTK has no concept of. kde-gtk-config maps what it can
+ // and may pass these through; rendering an unknown box would be worse than
+ // dropping it, which is what GTK itself does.
+ assert.deepEqual(
+ parseDecorationLayout(
+ "menu,applicationmenu:shade,keepabove,keepbelow,help,minimize,maximize,close",
+ ),
+ { left: [], right: ["minimize", "maximize", "close"] },
+ );
+});
+
+test("a duplicated control is rendered once", () => {
+ assert.deepEqual(parseDecorationLayout("close:close,minimize"), {
+ left: ["close"],
+ right: ["minimize"],
+ });
+});
+
+test("whitespace and capitalization are tolerated", () => {
+ assert.deepEqual(parseDecorationLayout(" : Minimize , CLOSE "), {
+ left: [],
+ right: ["minimize", "close"],
+ });
+});
+
+test("a string with no colon is entirely the left side, as GTK reads it", () => {
+ // `g_strsplit(layout, ":", 2)` leaves the right-hand token NULL, so GTK puts
+ // every button on the left. No mainstream desktop emits this, but matching
+ // GTK is the only defensible reading.
+ assert.deepEqual(parseDecorationLayout("minimize,close"), {
+ left: ["minimize", "close"],
+ right: [],
+ });
+});
+
+test("only the first colon splits the sides", () => {
+ // GTK's split has a limit of 2, so the second colon is not a separator: the
+ // right side becomes the single token "minimize:maximize", which matches no
+ // button name and is dropped — exactly as GTK drops it.
+ assert.deepEqual(parseDecorationLayout("close:minimize:maximize"), {
+ left: ["close"],
+ right: [],
+ });
+});
+
+test("missing, empty and unusable layouts fall back to the default", () => {
+ const fallback = { left: [], right: ["minimize", "maximize", "close"] };
+ for (const input of [null, undefined, "", " "]) {
+ assert.deepEqual(parseDecorationLayout(input), fallback, `input: ${input}`);
+ }
+ // A layout naming only buttons we cannot draw would otherwise leave the user
+ // with no way to close the window.
+ assert.deepEqual(parseDecorationLayout("appmenu:spacer"), fallback);
+ assert.deepEqual(parseDecorationLayout(":"), fallback);
+});
+
+test("the documented default parses to the default", () => {
+ assert.deepEqual(parseDecorationLayout(DEFAULT_DECORATION_LAYOUT), {
+ left: [],
+ right: ["minimize", "maximize", "close"],
+ });
+});
diff --git a/src/lib/window-decorations.ts b/src/lib/window-decorations.ts
new file mode 100644
index 0000000..43bcaab
--- /dev/null
+++ b/src/lib/window-decorations.ts
@@ -0,0 +1,86 @@
+/**
+ * Parsing for the desktop's titlebar button layout on Linux.
+ *
+ * The backend reads `GtkSettings::gtk-decoration-layout`, which GNOME populates
+ * from `org.gnome.desktop.wm.preferences button-layout` and KDE populates via
+ * `kde-gtk-config` from KWin's decoration settings. The grammar is the one GTK
+ * itself parses: a comma-separated list of button names for the left side, a
+ * `:`, then the list for the right side. Either side may be empty.
+ *
+ * ":minimize,maximize,close" GNOME default — everything on the right
+ * "close,minimize,maximize:" macOS-like — everything on the left
+ * "appmenu:close" upstream GTK default
+ */
+
+/** What the backend reports about this window's decorations. */
+export interface WindowDecorationsInfo {
+ /** True when the app owns the titlebar and must draw controls and edges. */
+ client_side: boolean;
+ /** The desktop's button layout; only meaningful when `client_side`. */
+ layout: string | null;
+}
+
+/** Controls this app can actually draw. */
+export type WindowControl = "minimize" | "maximize" | "close";
+
+export interface DecorationLayout {
+ left: WindowControl[];
+ right: WindowControl[];
+}
+
+/** What an unconfigured GNOME or KDE session shows. */
+export const DEFAULT_DECORATION_LAYOUT = ":minimize,maximize,close";
+
+const CONTROLS: WindowControl[] = ["minimize", "maximize", "close"];
+
+function parseSide(side: string, seen: Set): WindowControl[] {
+ const out: WindowControl[] = [];
+ for (const raw of side.split(",")) {
+ const name = raw.trim().toLowerCase();
+ // Everything else a desktop can put here is deliberately dropped rather
+ // than rendered as an unknown box: `icon`, `menu`, `appmenu` and `spacer`
+ // from GTK, plus KDE's extras (`shade`, `above`/`keepabove`,
+ // `below`/`keepbelow`, `help`, `applicationmenu`, `ontop`). Silently
+ // ignoring an unrecognized token is also what GTK does.
+ if (!CONTROLS.includes(name as WindowControl)) {
+ continue;
+ }
+ const control = name as WindowControl;
+ // A desktop could list the same button on both sides; the shared `seen` set
+ // means the first occurrence wins and it is never drawn twice.
+ if (seen.has(control)) {
+ continue;
+ }
+ seen.add(control);
+ out.push(control);
+ }
+ return out;
+}
+
+/**
+ * Split a layout string into the buttons to draw on each side.
+ *
+ * Falls back to the GNOME/KDE default whenever the string is missing, empty, or
+ * names no button this app can draw — a titlebar with no way to close the
+ * window would be far worse than one that ignores an exotic preference.
+ */
+export function parseDecorationLayout(
+ layout: string | null | undefined,
+): DecorationLayout {
+ const source = layout?.trim() ? layout : DEFAULT_DECORATION_LAYOUT;
+ // GTK splits on the FIRST colon only, and a string with no colon at all is
+ // entirely the left side (`g_strsplit(layout, ":", 2)` leaves the right
+ // token NULL). Matching that exactly beats inventing a friendlier rule.
+ const split = source.indexOf(":");
+ const leftRaw = split === -1 ? source : source.slice(0, split);
+ const rightRaw = split === -1 ? "" : source.slice(split + 1);
+
+ const seen = new Set();
+ const left = parseSide(leftRaw, seen);
+ const right = parseSide(rightRaw, seen);
+
+ if (left.length === 0 && right.length === 0) {
+ return { left: [], right: [...CONTROLS] };
+ }
+ return { left, right };
+}