feat: cookie bot

This commit is contained in:
zhom
2026-08-03 07:57:45 +04:00
parent 04b9617631
commit 7d82a25107
46 changed files with 15059 additions and 148 deletions
+29
View File
@@ -248,6 +248,35 @@ export const commandCoverage = {
"team_lock::get_team_lock_status",
],
},
remoteSessions: {
suite: "integrations",
level: "contract",
commands: [
"list_remote_sessions",
"get_remote_session",
"stop_remote_session",
"start_remote_session_events",
"stop_remote_session_events",
"get_remote_session_events_status",
],
},
cookieBot: {
suite: "integrations",
level: "contract",
commands: [
"get_cookie_bot_schedules",
"get_cookie_bot_schedule",
"save_cookie_bot_schedule",
"delete_cookie_bot_schedule",
"check_cookie_bot_conflicts",
"get_cookie_bot_runs",
"run_cookie_bot_now",
"cancel_cookie_bot_run",
"get_cookie_bot_presets",
"get_remote_hours_quota",
"get_cookie_bot_usage",
],
},
updateContracts: {
suite: "integrations",
level: "contract",
+110
View File
@@ -652,6 +652,116 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
assert.ok(versionStatus && typeof versionStatus === "object");
assert.equal(typeof (await app.invoke("is_default_browser")), "boolean");
// Remote sessions and the cookie bot are brokered by the cloud backend.
// Signed out, every one of them must fail as a code the UI can
// translate — a raw English string from the transport would reach the
// user untranslated, which is what the {"code":…} convention prevents.
const notSignedIn = /"code":"CLOUD_NOT_SIGNED_IN"/;
const missingProfileId = "00000000-0000-0000-0000-0000000000ff";
assert.match(await app.invokeError("list_remote_sessions"), notSignedIn);
assert.match(
await app.invokeError("get_remote_session", {
sessionId: "missing-e2e-session",
}),
notSignedIn,
);
assert.match(
await app.invokeError("stop_remote_session", {
sessionId: "missing-e2e-session",
}),
notSignedIn,
);
// The transition stream is what the desktop uses instead of polling, so
// its subscriber has to start, report itself, and stop on demand. Both
// calls are repeated: a second start must not open a second socket, and
// a second stop must not fail.
assert.equal(await app.invoke("get_remote_session_events_status"), false);
await app.invoke("start_remote_session_events");
assert.equal(await app.invoke("get_remote_session_events_status"), true);
await app.invoke("start_remote_session_events");
assert.equal(await app.invoke("get_remote_session_events_status"), true);
await app.invoke("stop_remote_session_events");
assert.equal(await app.invoke("get_remote_session_events_status"), false);
await app.invoke("stop_remote_session_events");
assert.equal(await app.invoke("get_remote_session_events_status"), false);
assert.match(
await app.invokeError("get_cookie_bot_schedules", { scope: "mine" }),
notSignedIn,
);
assert.match(
await app.invokeError("get_cookie_bot_schedule", {
profileId: missingProfileId,
}),
notSignedIn,
);
assert.match(
await app.invokeError("delete_cookie_bot_schedule", {
profileId: missingProfileId,
}),
notSignedIn,
);
assert.match(
await app.invokeError("check_cookie_bot_conflicts", {
profileId: missingProfileId,
runAtMinute: 120,
daysMask: 127,
}),
notSignedIn,
);
assert.match(
await app.invokeError("get_cookie_bot_runs", { limit: 10 }),
notSignedIn,
);
assert.match(
await app.invokeError("cancel_cookie_bot_run", {
runId: "missing-e2e-run",
}),
notSignedIn,
);
assert.match(
await app.invokeError("get_cookie_bot_presets"),
notSignedIn,
);
assert.match(
await app.invokeError("get_remote_hours_quota"),
notSignedIn,
);
assert.match(
await app.invokeError("get_cookie_bot_usage", { period: "2026-01" }),
notSignedIn,
);
// Enrolling and running act on a profile this machine holds: both are
// refused before any network call when it does not exist, so a bad id
// can never reach a leased host or an hour of the pooled budget.
assert.match(
await app.invokeError("save_cookie_bot_schedule", {
profileId: missingProfileId,
schedule: {
profile_name: "E2E missing profile",
platform: "windows",
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "UTC",
preset: "balanced",
max_minutes: 60,
sites: ["https://example.com"],
},
acknowledgeConflict: false,
}),
/"code":"PROFILE_NOT_FOUND"/,
);
assert.match(
await app.invokeError("run_cookie_bot_now", {
profileId: missingProfileId,
maxMinutes: 30,
}),
/"code":"PROFILE_NOT_FOUND"/,
);
const trial = await app.invoke("get_commercial_trial_status");
assert.ok(trial && typeof trial === "object");
await app.invoke("acknowledge_trial_expiration");
+2 -1
View File
@@ -10,8 +10,9 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
"test": "pnpm test:themes && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"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:themes": "node --test src/lib/themes.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",
"licenses:generate": "node scripts/generate-licenses.mjs",
File diff suppressed because it is too large Load Diff
+18
View File
@@ -43,10 +43,20 @@ pub struct Entitlements {
pub cloud_backup: bool,
#[serde(rename = "teamCollaboration", default)]
pub team_collaboration: bool,
/// Overnight profile warming on a leased remote host. Present on the wire
/// since the cookie-bot release; a field missing here is silently dropped on
/// the way to the UI, which is why every mirror of this struct has to move
/// together.
#[serde(rename = "cookieBot", default)]
pub cookie_bot: bool,
#[serde(rename = "profileLimit", default)]
pub profile_limit: i64,
#[serde(rename = "requestsPerHour", default)]
pub requests_per_hour: i64,
/// Per-seat monthly remote-session allowance. Reporting only — a team pools
/// it across seats, so the spendable figure comes from the quota route.
#[serde(rename = "remoteBrowserHours", default)]
pub remote_browser_hours: i64,
}
/// Local fallback mirror of the backend plan -> capability matrix, used only when
@@ -66,8 +76,10 @@ fn derive_entitlements(
cross_os_fingerprints: false,
cloud_backup: false,
team_collaboration: false,
cookie_bot: false,
profile_limit: 0,
requests_per_hour: 0,
remote_browser_hours: 0,
};
}
// pro and any unrecognized paid plan -> pro-level (never team).
@@ -82,12 +94,18 @@ fn derive_entitlements(
cross_os_fingerprints,
cloud_backup,
team_collaboration,
// A bot run IS remote automation on leased hardware, so the two capabilities
// never diverge: a plan that cannot drive a browser cannot warm one either.
cookie_bot: browser_automation,
profile_limit,
requests_per_hour: if browser_automation {
DEFAULT_REQUESTS_PER_HOUR
} else {
0
},
// Deliberately 0 in the fallback: the allowance is the server's to state and
// guessing it here would show a customer hours they may not have.
remote_browser_hours: 0,
}
}
+438
View File
@@ -0,0 +1,438 @@
//! Turning a donutbrowser-infra HTTP failure into a stable, translatable code.
//!
//! Every cloud transport in this crate flattens its failures through
//! `api_call_with_retry`, which needs a `String` so it can sniff for a 401.
//! That flattening loses the status, and the body it carries is the backend's
//! own English — which would reach the user untranslated, the exact bug the
//! `{"code":…}` convention exists to prevent.
//!
//! So the backend sends a machine code, this module recovers it, and the
//! frontend resolves it through `translateBackendError`. When the backend
//! sends something else (a proxy error page, a gateway 502), the status alone
//! still picks a code the user can act on.
use serde_json::Value;
use std::collections::BTreeMap;
/// A backend failure reduced to the shape `translateBackendError` consumes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendFailure {
/// The HTTP status it came from. 0 when the request never got that far.
pub status: u16,
pub code: String,
pub params: BTreeMap<String, String>,
}
impl BackendFailure {
/// Render as the `{"code":…,"params":{…}}` string a Tauri command returns.
pub fn to_error_json(&self) -> String {
let mut object = serde_json::Map::new();
object.insert("code".to_string(), Value::String(self.code.clone()));
if !self.params.is_empty() {
let params = self
.params
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect::<serde_json::Map<_, _>>();
object.insert("params".to_string(), Value::Object(params));
}
Value::Object(object).to_string()
}
}
/// Which code a status maps to when the body carries none.
///
/// 404 and 409 mean different things per route — "no schedule for this
/// profile" and "that run id is not yours" are both 404 — so each caller
/// supplies its own, rather than every route sharing one vague code.
#[derive(Debug, Clone, Copy)]
pub struct FailureCodes {
pub bad_request: &'static str,
pub forbidden: &'static str,
pub not_found: &'static str,
pub conflict: &'static str,
}
/// The desktop has no cloud session at all.
pub const NOT_SIGNED_IN: &str = "CLOUD_NOT_SIGNED_IN";
/// The request never reached donutbrowser-infra.
pub const UNREACHABLE: &str = "CLOUD_UNREACHABLE";
/// The backend answered, but with nothing the user can act on.
pub const UNAVAILABLE: &str = "CLOUD_REQUEST_FAILED";
/// Too many automation requests, backend side.
pub const RATE_LIMITED: &str = "REMOTE_RATE_LIMITED";
/// No host of the profile's OS has a free slot.
pub const NO_CAPACITY: &str = "REMOTE_NO_CAPACITY";
/// Recover `(status, body)` from the string `api_call_with_retry` hands back.
///
/// The transports encode a non-2xx as `"(503) no macos host free"` so the
/// helper can spot a 401 and still let the caller recover the kind. Anything
/// that is not that shape is a transport failure, not a status.
pub fn split_status(message: &str) -> Option<(u16, &str)> {
let rest = message.strip_prefix('(')?;
let (code, tail) = rest.split_once(')')?;
let status = code.trim().parse::<u16>().ok()?;
Some((status, tail.trim()))
}
/// Classify one HTTP failure.
pub fn classify(status: u16, body: &str, codes: FailureCodes) -> BackendFailure {
if let Some(failure) = from_body(status, body) {
return failure;
}
BackendFailure {
status,
code: code_for_status(status, codes).to_string(),
params: BTreeMap::new(),
}
}
/// Classify a flattened error string, whether or not it encodes a status.
///
/// Some callers strip the status before they get here (a typed error that
/// kept only the body), so a bare `{"code":…}` envelope is still recognised.
pub fn classify_message(message: &str, codes: FailureCodes) -> BackendFailure {
if let Some((status, body)) = split_status(message) {
return classify(status, body, codes);
}
if let Some(failure) = from_body(0, message) {
return failure;
}
transport_failure(message)
}
/// A failure that never became an HTTP response.
///
/// `api_call_with_retry` reports a missing token as plain text, so the
/// signed-out case is recognised here rather than surfacing as "something went
/// wrong" — being signed out is a state the user can fix.
pub fn transport_failure(message: &str) -> BackendFailure {
let code = if message.contains("Not logged in") || message.contains("No refresh token") {
NOT_SIGNED_IN
} else {
UNREACHABLE
};
BackendFailure {
status: 0,
code: code.to_string(),
params: BTreeMap::new(),
}
}
fn code_for_status(status: u16, codes: FailureCodes) -> &'static str {
match status {
400 | 422 => codes.bad_request,
401 => NOT_SIGNED_IN,
402 | 403 => codes.forbidden,
404 => codes.not_found,
409 => codes.conflict,
429 => RATE_LIMITED,
503 => NO_CAPACITY,
_ => UNAVAILABLE,
}
}
/// Read the backend's own `{"code":…}` envelope when it sent one.
fn from_body(status: u16, body: &str) -> Option<BackendFailure> {
let parsed = serde_json::from_str::<Value>(body).ok()?;
let object = parsed.as_object()?;
let code = object.get("code")?.as_str()?;
if code.is_empty() {
return None;
}
let mut params = BTreeMap::new();
// The nested shape first, so a top-level key of the same name still wins.
//
// The cookie-bot routes send every interpolated value under `params`
// (`{"code":…,"params":{…}}`) while the remote-session routes spread theirs
// at the top level. Only the flat one was read, so
// COOKIE_BOT_INVALID_TIMEZONE rendered with an empty timezone name,
// COOKIE_BOT_SITE_LIMIT always showed the hardcoded fallback, and a team out
// of hours was told it had "used 0 of 0".
if let Some(Value::Object(nested)) = object.get("params") {
collect_scalars(nested, &mut params);
}
for (key, value) in object {
if key == "code" || key == "params" {
continue;
}
if let Value::Array(items) = value {
if key == "conflicts" {
collect_conflict_params(items, &mut params);
}
continue;
}
if let Some(text) = scalar(value) {
params.insert(key.clone(), text);
}
}
Some(BackendFailure {
status,
code: code.to_string(),
params,
})
}
/// A JSON value that can be substituted into a translated sentence.
///
/// An object or an array has no rendering, so it is dropped rather than
/// stringified into the user's face.
fn scalar(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Number(number) => Some(number.to_string()),
Value::Bool(flag) => Some(flag.to_string()),
_ => None,
}
}
fn collect_scalars(object: &serde_json::Map<String, Value>, params: &mut BTreeMap<String, String>) {
for (key, value) in object {
if let Some(text) = scalar(value) {
params.insert(key.clone(), text);
}
}
}
/// Name the teammate whose enrolment blocks this one.
///
/// A schedule conflict is only actionable if the user learns WHO and WHEN, and
/// the list arrives as an array the generic scalar copy would drop. Only the
/// first entry is surfaced; the full list is in the response body for the UI.
fn collect_conflict_params(items: &[Value], params: &mut BTreeMap<String, String>) {
params.insert("conflict_count".to_string(), items.len().to_string());
let Some(first) = items.first().and_then(Value::as_object) else {
return;
};
if let Some(email) = first.get("email").and_then(Value::as_str) {
params.insert("email".to_string(), email.to_string());
}
if let Some(timezone) = first.get("timezone").and_then(Value::as_str) {
params.insert("timezone".to_string(), timezone.to_string());
}
if let Some(minute) = first.get("run_at_minute").and_then(Value::as_u64) {
params.insert("run_at_minute".to_string(), minute.to_string());
params.insert("time".to_string(), format_minute_of_day(minute));
}
}
/// Minute-of-day to a zero-padded 24h clock reading.
///
/// The value is a wall-clock offset in the conflicting enrolment's own
/// timezone, so there is no date and nothing to convert — only to render.
pub fn format_minute_of_day(minute: u64) -> String {
let minute = minute % 1440;
format!("{:02}:{:02}", minute / 60, minute % 60)
}
#[cfg(test)]
mod tests {
use super::*;
const CODES: FailureCodes = FailureCodes {
bad_request: "BAD",
forbidden: "FORBIDDEN",
not_found: "MISSING",
conflict: "CLASH",
};
#[test]
fn the_backends_own_code_wins_over_the_status_default() {
// The status table is a fallback for gateway pages. When infra names the
// failure, that name is the one the user's locale has a string for.
let failure = classify(403, r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#, CODES);
assert_eq!(failure.code, "COOKIE_BOT_NOT_ENTITLED");
assert_eq!(failure.status, 403);
}
#[test]
fn a_body_without_a_code_falls_back_to_the_routes_own_meaning() {
// 404 means "no schedule" on one route and "no such run" on another;
// sharing one code would tell the user the wrong thing on one of them.
assert_eq!(classify(404, "Not Found", CODES).code, "MISSING");
assert_eq!(classify(409, "", CODES).code, "CLASH");
assert_eq!(classify(400, "<html>", CODES).code, "BAD");
}
#[test]
fn capacity_and_rate_limits_are_never_reported_as_a_fault() {
// 503 is "come back in a minute" — the fleet is four Windows hosts wide,
// so a busy fleet is normal and must not look like an outage.
assert_eq!(classify(503, "", CODES).code, NO_CAPACITY);
assert_eq!(classify(429, "", CODES).code, RATE_LIMITED);
}
#[test]
fn an_unauthenticated_response_is_always_the_signed_out_code() {
// Never the route's forbidden code: "sign in" and "upgrade your plan" are
// different instructions and the user can only follow one of them.
assert_eq!(classify(401, "", CODES).code, NOT_SIGNED_IN);
assert_eq!(classify(402, "", CODES).code, "FORBIDDEN");
}
#[test]
fn scalar_body_fields_become_translation_params() {
let failure = classify(
403,
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"used":201.5,"pooled":true}"#,
CODES,
);
assert_eq!(
failure.params.get("granted").map(String::as_str),
Some("200")
);
assert_eq!(
failure.params.get("used").map(String::as_str),
Some("201.5")
);
assert_eq!(
failure.params.get("pooled").map(String::as_str),
Some("true")
);
}
#[test]
fn nested_params_are_read_because_that_is_the_shape_cookie_bot_sends() {
// `body(code, params)` in cookie-bot.errors.ts returns `{code, params}`,
// which Nest serialises verbatim. Reading only the top level dropped every
// interpolated value: the timezone the user typed, the site limit, the
// hours a team had actually spent.
let failure = classify(
400,
r#"{"code":"COOKIE_BOT_INVALID_TIMEZONE","params":{"timezone":"Europe/Nowhere"}}"#,
CODES,
);
assert_eq!(failure.code, "COOKIE_BOT_INVALID_TIMEZONE");
assert_eq!(
failure.params.get("timezone").map(String::as_str),
Some("Europe/Nowhere")
);
let limit = classify(
400,
r#"{"code":"COOKIE_BOT_SITE_LIMIT","params":{"min":1,"max":40}}"#,
CODES,
);
assert_eq!(limit.params.get("min").map(String::as_str), Some("1"));
assert_eq!(limit.params.get("max").map(String::as_str), Some("40"));
let hours = classify(
403,
r#"{"code":"REMOTE_HOURS_EXHAUSTED","params":{"granted":200,"used":214.5}}"#,
CODES,
);
assert_eq!(hours.params.get("granted").map(String::as_str), Some("200"));
assert_eq!(hours.params.get("used").map(String::as_str), Some("214.5"));
}
#[test]
fn both_body_shapes_coexist_and_the_top_level_one_wins() {
// The two planes disagree about where params live, and neither is going to
// change for the other. A key present in both must resolve once.
let failure = classify(
403,
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"params":{"granted":1,"used":5}}"#,
CODES,
);
assert_eq!(
failure.params.get("granted").map(String::as_str),
Some("200")
);
assert_eq!(failure.params.get("used").map(String::as_str), Some("5"));
}
#[test]
fn a_non_scalar_param_is_dropped_rather_than_rendered_as_json() {
// These values are substituted into a translated sentence. An object has
// no rendering, and `[object Object]` in a toast is worse than nothing.
let failure = classify(
400,
r#"{"code":"COOKIE_BOT_INVALID_SCHEDULE","params":{"field":"sites","detail":{"a":1},"list":[1,2]}}"#,
CODES,
);
assert_eq!(
failure.params.get("field").map(String::as_str),
Some("sites")
);
assert!(!failure.params.contains_key("detail"));
assert!(!failure.params.contains_key("list"));
}
#[test]
fn a_schedule_conflict_names_the_teammate_and_the_time() {
// Without these the dialog can only say "someone else already warms this
// profile", which is not something the user can act on.
let failure = classify(
409,
r#"{"code":"COOKIE_BOT_SCHEDULE_CONFLICT","conflicts":[{"email":"alex@example.com","run_at_minute":120,"timezone":"Europe/Berlin"}]}"#,
CODES,
);
assert_eq!(
failure.params.get("email").map(String::as_str),
Some("alex@example.com")
);
assert_eq!(
failure.params.get("time").map(String::as_str),
Some("02:00")
);
assert_eq!(
failure.params.get("conflict_count").map(String::as_str),
Some("1")
);
}
#[test]
fn minute_of_day_renders_as_a_padded_clock_reading() {
assert_eq!(format_minute_of_day(0), "00:00");
assert_eq!(format_minute_of_day(9 * 60 + 5), "09:05");
assert_eq!(format_minute_of_day(1439), "23:59");
}
#[test]
fn a_status_encoded_message_round_trips_to_its_code() {
assert_eq!(
classify_message(r#"(409) {"code":"COOKIE_BOT_RUN_IN_PROGRESS"}"#, CODES).code,
"COOKIE_BOT_RUN_IN_PROGRESS"
);
}
#[test]
fn a_signed_out_desktop_is_told_to_sign_in_not_that_the_network_failed() {
assert_eq!(classify_message("Not logged in", CODES).code, NOT_SIGNED_IN);
assert_eq!(
classify_message("reach backend: connection refused", CODES).code,
UNREACHABLE
);
}
#[test]
fn the_rendered_json_is_what_translate_backend_error_parses() {
let failure = classify(404, r#"{"code":"COOKIE_BOT_NOT_ENROLLED"}"#, CODES);
assert_eq!(
failure.to_error_json(),
r#"{"code":"COOKIE_BOT_NOT_ENROLLED"}"#
);
let with_params = classify(
403,
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200}"#,
CODES,
);
let parsed: Value = serde_json::from_str(&with_params.to_error_json())
.expect("the rendered error must be valid JSON");
assert_eq!(parsed["code"], "REMOTE_HOURS_EXHAUSTED");
assert_eq!(parsed["params"]["granted"], "200");
}
#[test]
fn split_status_does_not_misread_ordinary_prose() {
assert_eq!(split_status("(503) busy"), Some((503, "busy")));
assert_eq!(split_status("(nope) busy"), None);
assert_eq!(split_status("decode response: expected value"), None);
}
}
File diff suppressed because it is too large Load Diff
+294
View File
@@ -82,7 +82,9 @@ mod wayfern_manager;
mod wayfern_terms;
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
pub mod cloud_auth;
mod cloud_errors;
mod commercial_license;
mod cookie_bot;
mod cookie_manager;
pub mod events;
mod mcp_integrations;
@@ -1286,6 +1288,247 @@ async fn generate_sample_fingerprint(
}
}
// --- Remote sessions --------------------------------------------------------
//
// Everything below is transport only. The session state machine, the fleet, the
// schedule, the browsing behaviour and the budget all live behind
// donutbrowser-infra; these commands carry the user's own scalars there and
// render back what the server says.
/// Turn a remote-session failure into the code the frontend translates.
///
/// The typed variants carry the backend's own English, which reaches the user
/// untranslated if it is surfaced as-is. The raw text is kept in the app log,
/// where support can read it, and never in the toast.
fn remote_session_error(context: &str, err: remote_session::RemoteSessionError) -> String {
log::warn!("Remote session {context} failed: {err}");
err.to_error_json()
}
/// Every remote session the signed-in user currently owns.
#[tauri::command]
async fn list_remote_sessions() -> Result<Vec<remote_session::RemoteSessionState>, String> {
remote_session::list_remote_sessions()
.await
.map_err(|e| remote_session_error("list", e))
}
/// One session's real state.
///
/// The stream is how the desktop normally learns a transition; this is the
/// one-shot read for a window opened after the fact, or a reconnect confirming
/// what it missed.
#[tauri::command]
async fn get_remote_session(
session_id: String,
) -> Result<remote_session::RemoteSessionState, String> {
remote_session::get_remote_session(&session_id)
.await
.map_err(|e| remote_session_error("read", e))
}
/// Stop a remote session and settle what it cost.
///
/// Without this the only thing that ends a session is the fleet's two-hour cap,
/// so a handful of short launches bills an allowance meant for a hundred.
#[tauri::command]
async fn stop_remote_session(
session_id: String,
) -> Result<remote_session::EndRemoteSessionOutcome, String> {
remote_session::end_remote_session(&session_id)
.await
.map_err(|e| remote_session_error("stop", e))
}
/// Subscribe to session transitions. Idempotent.
///
/// Called once the desktop has a cloud session: signed out there is nothing to
/// stream and the socket would only be refused on a loop.
#[tauri::command]
fn start_remote_session_events(app_handle: tauri::AppHandle) {
remote_session::start_session_events(app_handle);
}
/// Unsubscribe. Safe when nothing is running; called on sign-out.
#[tauri::command]
fn stop_remote_session_events() {
remote_session::stop_session_events();
}
/// Whether the desktop is subscribed to session transitions.
///
/// A UI that mounts after the stream started has no `remote-session-stream`
/// event to read, so this is how it decides whether to trust the live state or
/// fall back to `list_remote_sessions`.
#[tauri::command]
fn get_remote_session_events_status() -> bool {
remote_session::session_events_running()
}
// --- Cookie bot -------------------------------------------------------------
/// Turn a cookie-bot failure into the code the frontend translates.
fn cookie_bot_error(context: &str, err: cookie_bot::CookieBotError) -> String {
log::warn!(
"Cookie bot {context} failed: {} (HTTP {})",
err.code(),
err.status()
);
err.to_error_json()
}
/// The local profile a cookie-bot write refers to.
///
/// Enrolment and run-now act on a profile this machine holds: the client-side
/// preconditions read its sync mode, OS and exit node, and none of that can be
/// checked for a profile that is not here.
fn cookie_bot_profile(profile_id: &str) -> Result<profile::BrowserProfile, String> {
let profiles = profile::manager::ProfileManager::instance()
.list_profiles()
.map_err(|e| wrap_backend_error(e, "Failed to read profiles"))?;
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| backend_error("PROFILE_NOT_FOUND"))
}
/// Every enrolment the caller can see. `scope` is `mine` or `team`.
#[tauri::command]
async fn get_cookie_bot_schedules(
scope: Option<String>,
) -> Result<cookie_bot::CookieBotScheduleList, String> {
cookie_bot::list_schedules(scope.as_deref())
.await
.map_err(|e| cookie_bot_error("schedule list", e))
}
/// This profile's enrolment, or `None` when it has none.
#[tauri::command]
async fn get_cookie_bot_schedule(
profile_id: String,
) -> Result<Option<cookie_bot::CookieBotSchedule>, String> {
cookie_bot::get_schedule(&profile_id)
.await
.map_err(|e| cookie_bot_error("schedule read", e))
}
/// Create or replace this profile's enrolment.
///
/// `acknowledge_conflict` is the second half of a two-step write: a teammate's
/// existing enrolment refuses the first PUT and names them, and the same call
/// with the flag set goes through.
#[tauri::command]
async fn save_cookie_bot_schedule(
profile_id: String,
schedule: cookie_bot::CookieBotScheduleInput,
acknowledge_conflict: bool,
) -> Result<cookie_bot::CookieBotScheduleSaved, String> {
// 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)?;
// 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.
let schedule = schedule.with_profile_state(cookie_bot::profile_state(&profile));
cookie_bot::save_schedule(&profile_id, &schedule, acknowledge_conflict)
.await
.map_err(|e| cookie_bot_error("schedule write", e))
}
/// Turn the bot off for this profile. `false` means there was nothing enrolled.
#[tauri::command]
async fn delete_cookie_bot_schedule(profile_id: String) -> Result<bool, String> {
cookie_bot::delete_schedule(&profile_id)
.await
.map(|outcome| outcome.deleted)
.map_err(|e| cookie_bot_error("schedule delete", e))
}
/// Who else already warms this profile, without writing anything.
#[tauri::command]
async fn check_cookie_bot_conflicts(
profile_id: String,
run_at_minute: Option<u16>,
timezone: Option<String>,
days_mask: Option<u8>,
) -> Result<Vec<cookie_bot::CookieBotConflict>, String> {
cookie_bot::check_conflicts(&profile_id, run_at_minute, timezone.as_deref(), days_mask)
.await
.map(|check| check.conflicts)
.map_err(|e| cookie_bot_error("conflict check", e))
}
/// One page of run history, newest first.
#[tauri::command]
async fn get_cookie_bot_runs(
profile_id: Option<String>,
scope: Option<String>,
limit: Option<u32>,
before: Option<String>,
) -> Result<cookie_bot::CookieBotRunPage, String> {
cookie_bot::list_runs(
profile_id.as_deref(),
scope.as_deref(),
limit,
before.as_deref(),
)
.await
.map_err(|e| cookie_bot_error("run list", e))
}
/// Start a run now instead of waiting for tonight.
///
/// The preset and the site list come from the stored enrolment, so an
/// unenrolled profile is refused rather than run with client-chosen defaults.
#[tauri::command]
async fn run_cookie_bot_now(
profile_id: String,
max_minutes: Option<u32>,
) -> Result<cookie_bot::CookieBotRunStarted, String> {
cookie_bot::bot_precondition(&cookie_bot_profile(&profile_id)?)?;
cookie_bot::run_now(&profile_id, max_minutes)
.await
.map_err(|e| cookie_bot_error("run start", e))
}
/// Stop a run that is still going.
#[tauri::command]
async fn cancel_cookie_bot_run(run_id: String) -> Result<cookie_bot::CookieBotRun, String> {
cookie_bot::cancel_run(&run_id)
.await
.map_err(|e| cookie_bot_error("run cancel", e))
}
/// The intensities the server offers today. Opaque ids and a typical duration —
/// what each one actually does is the server's to know.
#[tauri::command]
async fn get_cookie_bot_presets() -> Result<cookie_bot::CookieBotPresetList, String> {
cookie_bot::list_presets()
.await
.map_err(|e| cookie_bot_error("preset list", e))
}
/// The pooled remote-hour budget: bot and interactive sessions share one pool.
///
/// Being refused a launch must not be the only way to learn a limit exists.
#[tauri::command]
async fn get_remote_hours_quota() -> Result<cookie_bot::RemoteHoursQuota, String> {
cookie_bot::remote_hours_quota()
.await
.map_err(|e| cookie_bot_error("quota read", e))
}
/// Per-member and per-profile spend for a calendar month (`YYYY-MM`).
#[tauri::command]
async fn get_cookie_bot_usage(
period: Option<String>,
) -> Result<cookie_bot::CookieBotUsage, String> {
cookie_bot::team_usage(period.as_deref())
.await
.map_err(|e| cookie_bot_error("usage read", e))
}
/// Confirm a quit chosen from the close-confirmation dialog and exit the app.
#[tauri::command]
fn confirm_quit(app_handle: tauri::AppHandle) {
@@ -2286,6 +2529,12 @@ pub fn run_with_builder(
}
};
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
// Subscribe to remote-session transitions. Started here rather than
// unconditionally because a signed-out desktop has nothing to stream
// and would only be refused on a loop; the frontend starts it again
// through `start_remote_session_events` once the user signs in.
remote_session::start_session_events(app_handle_cloud.clone());
}
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
});
@@ -2478,6 +2727,25 @@ pub fn run_with_builder(
dns_blocklist::set_custom_dns_config,
dns_blocklist::import_custom_dns_rules,
dns_blocklist::export_custom_dns_rules,
// Remote session commands
list_remote_sessions,
get_remote_session,
stop_remote_session,
start_remote_session_events,
stop_remote_session_events,
get_remote_session_events_status,
// Cookie bot commands
get_cookie_bot_schedules,
get_cookie_bot_schedule,
save_cookie_bot_schedule,
delete_cookie_bot_schedule,
check_cookie_bot_conflicts,
get_cookie_bot_runs,
run_cookie_bot_now,
cancel_cookie_bot_run,
get_cookie_bot_presets,
get_remote_hours_quota,
get_cookie_bot_usage,
// Profile password commands
set_profile_password,
change_profile_password,
@@ -2490,6 +2758,12 @@ pub fn run_with_builder(
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_app_handle, _event| {
// Drop the session stream before the runtime goes away, so a shutdown
// never waits out a reconnect backoff that is about to be pointless.
if let tauri::RunEvent::Exit = _event {
remote_session::stop_session_events();
}
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen { .. } = _event {
if let Some(window) = _app_handle.get_webview_window("main") {
@@ -2523,6 +2797,26 @@ mod tests {
);
}
#[test]
fn the_frontend_listens_for_the_remote_session_events_that_are_emitted() {
// These names are the whole of BUG-2's fix: the backend answers a launch
// with `provisioning` and nothing else, so a desktop that subscribes to a
// name the emitter does not use is blind between launch and stop and shows
// nothing at all. Renaming one side is silent everywhere else.
let client = fs::read_to_string("../src/lib/remote-sessions.ts")
.expect("the frontend remote-session client must exist");
for event in [
crate::remote_session::EVENT_SESSION_STATE,
crate::remote_session::EVENT_SESSION_SNAPSHOT,
crate::remote_session::EVENT_STREAM_STATUS,
] {
assert!(
client.contains(&format!("\"{event}\"")),
"no frontend listener for the emitted event {event}"
);
}
}
#[test]
fn test_no_unused_tauri_commands() {
check_unused_commands(false); // Run in strict mode for CI
+817 -6
View File
@@ -509,6 +509,18 @@ impl McpServer {
| "get_interactive_elements"
| "click_by_index"
| "type_by_index"
// Starting a bot run leases a remote host for up to two hours and
// spends the account's pooled remote-hour budget, which makes it the
// most expensive tool here. Cancelling one reaches the same fleet, and
// is metered alongside the remote-session stop it mirrors.
//
// Deliberately absent: set_cookie_bot_schedule and
// delete_cookie_bot_schedule. They write one row in Donut cloud and
// lease nothing; metering them would throttle an agent enrolling a
// fleet of profiles, while the budget that actually guards the
// hardware is spent per RUN and enforced server-side.
| "run_cookie_bot_now"
| "cancel_cookie_bot_run"
)
}
@@ -1646,6 +1658,255 @@ impl McpServer {
"required": ["profile_id", "index", "text"]
}),
},
// Remote fleet observability. `run_profile_remote` hands back a session
// id and the word "provisioning"; without these an agent can only learn
// that a session became usable by trying to drive it and failing.
McpTool {
name: "list_remote_sessions".to_string(),
description: "List the remote browser sessions this account currently owns, with their live status".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "get_remote_session".to_string(),
description: "Read one remote session's real state: provisioning, ready, live or closed, plus whether it can be driven yet".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Session id returned when the remote session was started"
}
},
"required": ["session_id"]
}),
},
McpTool {
name: "get_remote_hours_quota".to_string(),
description: "Read the pooled remote-hour budget. Bot runs and interactive remote sessions spend the same pool".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
// Cookie bot. Every one of these is a proxy onto Donut cloud, which owns
// the schedule and the browsing behaviour; the tools carry only the
// user's own choices.
McpTool {
name: "list_cookie_bot_schedules".to_string(),
description: "List profiles enrolled in the nightly cookie bot".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"scope": {
"type": "string",
"enum": ["mine", "team"],
"description": "Whose enrolments to list (default: mine)"
}
},
"required": []
}),
},
McpTool {
name: "get_cookie_bot_schedule".to_string(),
description: "Get one profile's cookie-bot enrolment, or null when it is not enrolled".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "set_cookie_bot_schedule".to_string(),
description: "Enrol a profile in the nightly cookie bot, or replace its enrolment. The profile must have cloud sync (not end-to-end encrypted), a recorded Windows or macOS operating system, and a proxy or VPN".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to enrol"
},
"profile_name": {
"type": "string",
"description": "Label shown in run history (default: the profile's own name)"
},
"platform": {
"type": "string",
"enum": ["windows", "macos"],
"description": "Must match the profile's own operating system; taken from the profile when omitted"
},
"enabled": {
"type": "boolean",
"description": "Whether the nightly run is armed"
},
"run_at_minute": {
"type": "integer",
"description": "Minutes past local midnight, 0-1439"
},
"days_mask": {
"type": "integer",
"description": "Bitmask of local weekdays, bit 0 = Monday, 1-127"
},
"timezone": {
"type": "string",
"description": "IANA zone the run time is expressed in, e.g. Europe/Berlin"
},
"preset": {
"type": "string",
"description": "Preset id from list_cookie_bot_presets"
},
"max_minutes": {
"type": "integer",
"description": "Upper bound on one run, in minutes"
},
"sites": {
"type": "array",
"items": { "type": "string" },
"description": "Absolute http(s) URLs to browse. The bot visits only these"
},
"jitter_seconds": {
"type": "integer",
"description": "Random spread around the run time, in seconds"
},
"acknowledge_conflict": {
"type": "boolean",
"description": "Write anyway when a teammate already enrols this profile"
}
},
"required": ["profile_id", "enabled", "run_at_minute", "days_mask", "timezone", "preset", "max_minutes"]
}),
},
McpTool {
name: "delete_cookie_bot_schedule".to_string(),
description: "Turn the cookie bot off for a profile. Safe to repeat; a run already in flight is not cancelled".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to unenrol"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "check_cookie_bot_conflicts".to_string(),
description: "Ask, without writing anything, which teammates already enrol this profile and whether a proposed time would overlap theirs".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile"
},
"run_at_minute": {
"type": "integer",
"description": "Proposed minutes past local midnight, 0-1439"
},
"timezone": {
"type": "string",
"description": "Proposed IANA zone"
},
"days_mask": {
"type": "integer",
"description": "Proposed weekday bitmask, bit 0 = Monday"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "list_cookie_bot_runs".to_string(),
description: "List cookie-bot runs, newest first, with how many sites each visited and what it cost".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "Restrict to one profile"
},
"scope": {
"type": "string",
"enum": ["mine", "team"],
"description": "Whose runs to list (default: mine)"
},
"limit": {
"type": "integer",
"description": "Page size, 1-100 (default: 30)"
},
"before": {
"type": "string",
"description": "Keyset cursor from a previous page's next_before"
}
},
"required": []
}),
},
McpTool {
name: "run_cookie_bot_now".to_string(),
description: "Start a cookie-bot run immediately instead of waiting for the schedule. The profile must already be enrolled: the preset and site list live in its schedule. Requires an active Pro subscription and spends the pooled remote-hour budget".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the enrolled profile to warm"
},
"max_minutes": {
"type": "integer",
"description": "Cap this run only, overriding the schedule's own"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "cancel_cookie_bot_run".to_string(),
description: "Stop a cookie-bot run that is still going. Idempotent: cancelling a finished run returns it unchanged".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"run_id": {
"type": "string",
"description": "Run id from list_cookie_bot_runs"
}
},
"required": ["run_id"]
}),
},
McpTool {
name: "list_cookie_bot_presets".to_string(),
description: "List the cookie-bot intensities that can be chosen, with roughly how long each takes".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "get_cookie_bot_usage".to_string(),
description: "Per-member and per-profile cookie-bot spend for a calendar month. Reporting only".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"period": {
"type": "string",
"description": "Calendar month as YYYY-MM (default: the current UTC month)"
}
},
"required": []
}),
},
]
}
@@ -1991,6 +2252,33 @@ impl McpServer {
.await?;
self.handle_type_by_index(arguments).await
}
// Remote fleet observability. Reads only, and free: being unable to see
// that a session you are already paying for has become usable is not a
// feature worth withholding.
"list_remote_sessions" => Self::handle_list_remote_sessions().await,
"get_remote_session" => Self::handle_get_remote_session(arguments).await,
"get_remote_hours_quota" => Self::handle_get_remote_hours_quota().await,
// Cookie bot. Reading and configuring are free; only starting a run,
// which leases a host and spends the pooled hours, needs the plan.
"list_cookie_bot_schedules" => Self::handle_list_cookie_bot_schedules(arguments).await,
"get_cookie_bot_schedule" => Self::handle_get_cookie_bot_schedule(arguments).await,
"set_cookie_bot_schedule" => Self::handle_set_cookie_bot_schedule(arguments).await,
"delete_cookie_bot_schedule" => Self::handle_delete_cookie_bot_schedule(arguments).await,
"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?;
Self::handle_run_cookie_bot_now(arguments).await
}
// No capability gate on the cancel. A lapsed plan must never be the
// reason an agent cannot stop something that is spending hours.
"cancel_cookie_bot_run" => Self::handle_cancel_cookie_bot_run(arguments).await,
"list_cookie_bot_presets" => Self::handle_list_cookie_bot_presets().await,
"get_cookie_bot_usage" => Self::handle_get_cookie_bot_usage(arguments).await,
_ => Err(McpError {
code: -32602,
message: format!("Unknown tool: {tool_name}"),
@@ -5521,6 +5809,348 @@ impl McpServer {
}]
}))
}
// --- Remote fleet and cookie bot -----------------------------------------
//
// Every tool below is a proxy onto Donut cloud, which owns the schedule, the
// calendar arithmetic, the browsing behaviour and the pooled hour budget.
// Nothing here decides when a run happens or what it does. What this file
// DOES decide is which profiles may be offered to the bot at all.
/// Render a value as the single text block an MCP tool answers with.
fn json_content<T: Serialize>(value: &T) -> Result<serde_json::Value, McpError> {
let text = serde_json::to_string_pretty(value).map_err(|e| McpError {
code: -32000,
message: format!("Failed to encode response: {e}"),
})?;
Ok(serde_json::json!({ "content": [{ "type": "text", "text": text }] }))
}
fn require_str<'a>(arguments: &'a serde_json::Value, key: &str) -> Result<&'a str, McpError> {
arguments
.get(key)
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| McpError {
code: -32602,
message: format!("Missing {key}"),
})
}
/// Read a whole-number argument, refusing anything that would silently wrap.
///
/// `as_u64() as u16` would turn a run time of 1440 into 1440 but 65536 into
/// 0, quietly scheduling a run at midnight nobody asked for.
fn require_u16(arguments: &serde_json::Value, key: &str) -> Result<u16, McpError> {
Self::optional_u16(arguments, key)?.ok_or_else(|| McpError {
code: -32602,
message: format!("Missing {key}"),
})
}
fn optional_u16(arguments: &serde_json::Value, key: &str) -> Result<Option<u16>, McpError> {
let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else {
return Ok(None);
};
value
.as_u64()
.and_then(|raw| u16::try_from(raw).ok())
.map(Some)
.ok_or_else(|| McpError {
code: -32602,
message: format!("{key} must be a whole number between 0 and 65535"),
})
}
fn optional_u8(arguments: &serde_json::Value, key: &str) -> Result<Option<u8>, McpError> {
let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else {
return Ok(None);
};
value
.as_u64()
.and_then(|raw| u8::try_from(raw).ok())
.map(Some)
.ok_or_else(|| McpError {
code: -32602,
message: format!("{key} must be a whole number between 0 and 255"),
})
}
fn optional_u32(arguments: &serde_json::Value, key: &str) -> Result<Option<u32>, McpError> {
let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else {
return Ok(None);
};
value
.as_u64()
.and_then(|raw| u32::try_from(raw).ok())
.map(Some)
.ok_or_else(|| McpError {
code: -32602,
message: format!("{key} must be a whole number between 0 and 4294967295"),
})
}
/// A cloud failure, rendered as the `{"code":…,"params":{…}}` envelope.
///
/// The backend's own English would be meaningless to an agent deciding what
/// to do next; a stable code and its parameters are something it can branch
/// on, and it is the same envelope the desktop and the REST API answer with.
fn cloud_error(err: crate::cookie_bot::CookieBotError) -> McpError {
McpError {
code: -32000,
message: err.to_error_json(),
}
}
/// Resolve a profile the cookie bot is allowed to touch.
///
/// The same gate the REST surface applies, for the same reason: the bot runs
/// ONLY on the leased fleet, so a profile that cannot make the round trip to
/// a remote host and back — never synced, encrypted with a key that never
/// leaves this machine, no recorded OS, an OS the fleet cannot lease, or no
/// proxy or VPN to egress through — must never reach an enrolment, a quota
/// check or a leased host on ANY surface.
fn cookie_bot_eligible_profile(profile_id: &str) -> Result<BrowserProfile, McpError> {
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
})?;
let profile = profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| McpError {
code: -32000,
message: format!("Profile not found: {profile_id}"),
})?;
crate::cookie_bot::bot_precondition(&profile).map_err(|message| McpError {
code: -32000,
message,
})?;
Ok(profile)
}
async fn handle_list_remote_sessions() -> Result<serde_json::Value, McpError> {
let sessions = crate::remote_session::list_remote_sessions()
.await
.map_err(|e| McpError {
code: -32000,
message: e.to_error_json(),
})?;
Self::json_content(&sessions)
}
async fn handle_get_remote_session(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let session_id = Self::require_str(arguments, "session_id")?;
let state = crate::remote_session::get_remote_session(session_id)
.await
.map_err(|e| McpError {
code: -32000,
message: e.to_error_json(),
})?;
Self::json_content(&state)
}
async fn handle_get_remote_hours_quota() -> Result<serde_json::Value, McpError> {
let quota = crate::cookie_bot::remote_hours_quota()
.await
.map_err(Self::cloud_error)?;
Self::json_content(&quota)
}
async fn handle_list_cookie_bot_schedules(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let scope = arguments.get("scope").and_then(|value| value.as_str());
let schedules = crate::cookie_bot::list_schedules(scope)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&schedules)
}
async fn handle_get_cookie_bot_schedule(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
// Not gated on eligibility: a profile whose sync was turned off after it
// was enrolled must still be able to show what it is enrolled as.
let schedule = crate::cookie_bot::get_schedule(profile_id)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&schedule)
}
async fn handle_set_cookie_bot_schedule(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let profile = Self::cookie_bot_eligible_profile(profile_id)?;
// `bot_precondition` already proved the profile has an OS the fleet can
// lease. Taking the platform from the profile rather than the arguments is
// what stops an agent enrolling a macOS profile onto a Windows host.
let platform = profile
.resolved_os()
.ok_or_else(|| McpError {
code: -32000,
message: "Profile has no recorded operating system".to_string(),
})?
.to_string();
if let Some(requested) = arguments.get("platform").and_then(|v| v.as_str()) {
if requested != platform {
return Err(McpError {
code: -32602,
message: format!(
"platform {requested:?} does not match the profile's own operating system {platform:?}"
),
});
}
}
let enabled = arguments
.get("enabled")
.and_then(|value| value.as_bool())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing enabled".to_string(),
})?;
let sites = arguments
.get("sites")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let input = crate::cookie_bot::CookieBotScheduleInput {
profile_name: arguments
.get("profile_name")
.and_then(|value| value.as_str())
.map_or_else(|| profile.name.clone(), str::to_string),
platform,
enabled,
run_at_minute: Self::require_u16(arguments, "run_at_minute")?,
days_mask: Self::optional_u8(arguments, "days_mask")?.ok_or_else(|| McpError {
code: -32602,
message: "Missing days_mask".to_string(),
})?,
timezone: Self::require_str(arguments, "timezone")?.to_string(),
preset: Self::require_str(arguments, "preset")?.to_string(),
max_minutes: Self::optional_u32(arguments, "max_minutes")?.ok_or_else(|| McpError {
code: -32602,
message: "Missing max_minutes".to_string(),
})?,
sites,
jitter_seconds: Self::optional_u32(arguments, "jitter_seconds")?,
..Default::default()
}
// Derived from the profile, never from the tool arguments: an agent must not
// be able to claim a profile has a proxy when it does not.
.with_profile_state(crate::cookie_bot::profile_state(&profile));
let acknowledge_conflict = arguments
.get("acknowledge_conflict")
.and_then(|value| value.as_bool())
.unwrap_or(false);
let saved = crate::cookie_bot::save_schedule(profile_id, &input, acknowledge_conflict)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&saved)
}
async fn handle_delete_cookie_bot_schedule(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
// No eligibility gate: a profile that has since become ineligible is
// exactly the one an agent most needs to be able to unenrol.
let deleted = crate::cookie_bot::delete_schedule(profile_id)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&deleted)
}
async fn handle_check_cookie_bot_conflicts(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
let conflicts = crate::cookie_bot::check_conflicts(
profile_id,
Self::optional_u16(arguments, "run_at_minute")?,
arguments.get("timezone").and_then(|value| value.as_str()),
Self::optional_u8(arguments, "days_mask")?,
)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&conflicts)
}
async fn handle_list_cookie_bot_runs(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let runs = crate::cookie_bot::list_runs(
arguments.get("profile_id").and_then(|value| value.as_str()),
arguments.get("scope").and_then(|value| value.as_str()),
Self::optional_u32(arguments, "limit")?,
arguments.get("before").and_then(|value| value.as_str()),
)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&runs)
}
async fn handle_run_cookie_bot_now(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = Self::require_str(arguments, "profile_id")?;
Self::cookie_bot_eligible_profile(profile_id)?;
let started =
crate::cookie_bot::run_now(profile_id, Self::optional_u32(arguments, "max_minutes")?)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&started)
}
async fn handle_cancel_cookie_bot_run(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let run_id = Self::require_str(arguments, "run_id")?;
let run = crate::cookie_bot::cancel_run(run_id)
.await
.map_err(Self::cloud_error)?;
Self::json_content(&run)
}
async fn handle_list_cookie_bot_presets() -> Result<serde_json::Value, McpError> {
// Ids and a rough duration only. What a preset expands to — the site
// ordering, the dwell model, the scroll and click programme — is the
// server's, and stays there.
let presets = crate::cookie_bot::list_presets()
.await
.map_err(Self::cloud_error)?;
Self::json_content(&presets)
}
async fn handle_get_cookie_bot_usage(
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let usage = crate::cookie_bot::team_usage(arguments.get("period").and_then(|v| v.as_str()))
.await
.map_err(Self::cloud_error)?;
Self::json_content(&usage)
}
}
lazy_static::lazy_static! {
@@ -5536,8 +6166,21 @@ mod tests {
let server = McpServer::new();
let tools = server.get_tools();
// Should have at least 41 tools (34 + 7 browser interaction tools)
assert!(tools.len() >= 41);
// Should have at least 54 tools (34 + 7 browser interaction + 13 remote
// fleet and cookie-bot tools)
assert!(tools.len() >= 54);
// Names are the contract an MCP client is written against, so a duplicate
// silently shadows one of the two in dispatch and the tool that loses is
// simply never reachable.
let mut seen = std::collections::HashSet::new();
for tool in &tools {
assert!(
seen.insert(tool.name.as_str()),
"duplicate MCP tool name: {}",
tool.name
);
}
// Check tool names
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
@@ -5602,6 +6245,150 @@ mod tests {
assert!(tool_names.contains(&"type_text"));
assert!(tool_names.contains(&"get_page_content"));
assert!(tool_names.contains(&"get_page_info"));
// Remote fleet observability
assert!(tool_names.contains(&"list_remote_sessions"));
assert!(tool_names.contains(&"get_remote_session"));
assert!(tool_names.contains(&"get_remote_hours_quota"));
// Cookie bot
assert!(tool_names.contains(&"list_cookie_bot_schedules"));
assert!(tool_names.contains(&"get_cookie_bot_schedule"));
assert!(tool_names.contains(&"set_cookie_bot_schedule"));
assert!(tool_names.contains(&"delete_cookie_bot_schedule"));
assert!(tool_names.contains(&"check_cookie_bot_conflicts"));
assert!(tool_names.contains(&"list_cookie_bot_runs"));
assert!(tool_names.contains(&"run_cookie_bot_now"));
assert!(tool_names.contains(&"cancel_cookie_bot_run"));
assert!(tool_names.contains(&"list_cookie_bot_presets"));
assert!(tool_names.contains(&"get_cookie_bot_usage"));
}
// A tool advertised in tools/list but missing from dispatch answers "Unknown
// tool": the client can see it and cannot call it, and nothing else in the
// build notices.
//
// Asserted against the source rather than by dispatching, because half these
// tools take no arguments — calling them would reach Donut cloud, and a unit
// test that needs the network is a test that gets deleted.
#[test]
fn every_cookie_bot_tool_is_both_advertised_and_dispatchable() {
let server = McpServer::new();
let advertised: Vec<String> = server
.get_tools()
.into_iter()
.map(|tool| tool.name)
.filter(|name| name.contains("cookie_bot") || name.contains("remote_"))
.collect();
let dispatched = include_str!("mcp_server.rs");
for name in &advertised {
assert!(
dispatched.contains(&format!("\"{name}\" =>")),
"tool is advertised but has no dispatch arm: {name}"
);
}
assert_eq!(
advertised.len(),
13,
"expected the full remote-fleet and cookie-bot set: {advertised:?}"
);
}
// The bot runs ONLY on the leased fleet. A profile that cannot be
// materialised on a remote host has no path to a run, and every write tool
// resolves its profile through this gate before the cloud is asked, so there
// is no argument shape that points the bot at a local-only profile.
#[test]
fn a_profile_the_bot_could_never_run_is_refused_before_the_cloud_is_asked() {
use crate::profile::types::SyncMode;
let eligible = || BrowserProfile {
id: uuid::Uuid::nil(),
name: "warm me".to_string(),
browser: "wayfern".to_string(),
version: "latest".to_string(),
sync_mode: SyncMode::Regular,
host_os: Some("macos".to_string()),
proxy_id: Some("proxy-1".to_string()),
..Default::default()
};
assert!(crate::cookie_bot::bot_precondition(&eligible()).is_ok());
let mut local_only = eligible();
local_only.sync_mode = SyncMode::Disabled;
assert!(
crate::cookie_bot::bot_precondition(&local_only).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(),
"the fleet cannot lease a linux host"
);
let mut datacenter_egress = eligible();
datacenter_egress.proxy_id = None;
datacenter_egress.vpn_id = None;
assert!(
crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(),
"hours of traffic from a hosting ASN damages the identity being warmed"
);
}
// Enrolment carries only the user's own scalars. A site list, a dwell range
// or a step programme appearing in the schema would mean the browsing model
// had leaked out of the server and into this AGPL client.
#[test]
fn the_bot_tools_expose_choices_not_behaviour() {
let server = McpServer::new();
let tools = server.get_tools();
let presets = tools
.iter()
.find(|tool| tool.name == "list_cookie_bot_presets")
.expect("list_cookie_bot_presets tool");
assert_eq!(
presets.input_schema["properties"]
.as_object()
.map(serde_json::Map::len),
Some(0),
"a preset is chosen by id; it takes no behaviour parameters"
);
let set = tools
.iter()
.find(|tool| tool.name == "set_cookie_bot_schedule")
.expect("set_cookie_bot_schedule tool");
let properties = set.input_schema["properties"]
.as_object()
.expect("schedule properties");
for leaked in [
"dwell",
"dwell_seconds",
"scroll",
"clicks",
"steps",
"actions",
"corpus",
"user_agent",
] {
assert!(
!properties.contains_key(leaked),
"the browsing model leaked into the tool contract: {leaked}"
);
}
// `platform` is accepted but not required: this machine already knows the
// profile's operating system, and a supplied one that disagrees is
// refused rather than honoured.
let required = set.input_schema["required"]
.as_array()
.expect("required fields");
assert!(!required.iter().any(|field| field == "platform"));
assert!(required.iter().any(|field| field == "profile_id"));
assert!(required.iter().any(|field| field == "preset"));
}
#[test]
@@ -5658,6 +6445,11 @@ mod tests {
"get_interactive_elements",
"click_by_index",
"type_by_index",
// Leases a remote host for up to two hours and spends the pooled
// remote-hour budget.
"run_cookie_bot_now",
// Reaches the fleet, like the remote-session stop it mirrors.
"cancel_cookie_bot_run",
] {
assert!(
McpServer::is_automation_tool_call(&request("tools/call", Some(name))),
@@ -5665,10 +6457,29 @@ mod tests {
);
}
assert!(!McpServer::is_automation_tool_call(&request(
"tools/call",
Some("list_profiles")
)));
for name in [
"list_profiles",
// Configuration, not automation: one row in Donut cloud, no hardware
// leased. Metering it would throttle an agent enrolling a fleet of
// profiles, while the budget that guards the hardware is spent per run.
"set_cookie_bot_schedule",
"delete_cookie_bot_schedule",
"list_cookie_bot_schedules",
"get_cookie_bot_schedule",
"check_cookie_bot_conflicts",
"list_cookie_bot_runs",
"list_cookie_bot_presets",
"get_cookie_bot_usage",
"get_remote_hours_quota",
"list_remote_sessions",
"get_remote_session",
] {
assert!(
!McpServer::is_automation_tool_call(&request("tools/call", Some(name))),
"free or non-leasing tool was limited: {name}"
);
}
assert!(!McpServer::is_automation_tool_call(&request(
"tools/list",
None
+10
View File
@@ -1189,6 +1189,12 @@ impl ProfileManager {
crate::sync::queue_profile_sync_if_eligible(&profile);
// The cookie bot refuses a run on a profile with no exit node, using the
// copy of that fact the desktop last declared. Detaching a proxy has to
// move that copy, or tonight's run egresses from the leased host's own
// datacenter address.
crate::cookie_bot::report_profile_state(&profile);
// Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id {
@@ -1250,6 +1256,10 @@ impl ProfileManager {
crate::sync::queue_profile_sync_if_eligible(&profile);
// Same reason as the proxy path: a VPN is the profile's exit node too, and
// the server only knows what this machine last told it.
crate::cookie_bot::report_profile_state(&profile);
// Auto-enable sync for the new VPN if profile has sync enabled.
if profile.is_sync_enabled() {
if let Some(ref new_vpn_id) = vpn_id {
+822 -5
View File
@@ -6,10 +6,23 @@
//! That indirection is the point: a desktop client that could call the manager
//! itself would need credentials capable of launching sessions for anyone.
use crate::cloud_errors::{self, FailureCodes};
use crate::profile::types::BrowserProfile;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use tauri::AppHandle;
/// Which code a remote-session failure resolves to when the backend sends no
/// envelope of its own.
const SESSION_CODES: FailureCodes = FailureCodes {
bad_request: "REMOTE_SESSION_REFUSED",
forbidden: "REMOTE_NOT_ENTITLED",
not_found: "REMOTE_SESSION_NOT_FOUND",
conflict: "REMOTE_SESSION_CONFLICT",
};
/// Why a remote launch failed, mapped to the status the local API should return.
#[derive(Debug)]
pub enum RemoteSessionError {
@@ -32,6 +45,25 @@ impl std::fmt::Display for RemoteSessionError {
}
}
impl RemoteSessionError {
/// The `{"code":…,"params":{…}}` string a Tauri command returns.
///
/// The variants carry the backend's own English, which reaches the user
/// untranslated if it is surfaced as-is. This recovers the machine code the
/// frontend has a locale string for.
pub fn to_error_json(&self) -> String {
// The three typed variants know the status they came from; `Other` kept
// only the body, so it is re-read for an embedded envelope.
let (status, message) = match self {
Self::NoCapacity(m) => (503, m),
Self::Conflict(m) => (409, m),
Self::NotAuthorised(m) => (403, m),
Self::Other(m) => return cloud_errors::classify_message(m, SESSION_CODES).to_error_json(),
};
cloud_errors::classify(status, message, SESSION_CODES).to_error_json()
}
}
/// What the backend returns when a session starts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteSessionOutcome {
@@ -197,14 +229,513 @@ pub async fn end_remote_session(
/// status is re-parsed here rather than lost — a 503 surfacing as a generic
/// failure would tell the user their fleet is broken when it is merely busy.
pub fn classify_error_string(message: &str) -> RemoteSessionError {
if let Some(rest) = message.strip_prefix('(') {
if let Some((code, tail)) = rest.split_once(')') {
if let Ok(status) = code.trim().parse::<u16>() {
return classify_backend_status(status, tail.trim());
match cloud_errors::split_status(message) {
Some((status, body)) => classify_backend_status(status, body),
None => RemoteSessionError::Other(message.to_string()),
}
}
/// A session as the backend currently sees it.
///
/// `POST /api/remote-sessions` hands back the literal string `provisioning`
/// and nothing else, so until this type existed the only way anyone observed a
/// session becoming usable was by reading the production database.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RemoteSessionState {
pub session_id: String,
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub platform: Option<String>,
/// `provisioning` | `ready` | `live` | `closed` | `error`.
///
/// Named `state` because that is what `RemoteSessionView` in
/// donutbrowser-infra actually sends. It carried the name `status` until a
/// real payload was compared against it, and because the field had no
/// default, every list and single read failed at `missing field \`status\``
/// and surfaced as CLOUD_UNREACHABLE. The alias keeps the launch reply —
/// which predates the reconciled vocabulary and still says `status` —
/// decoding through the same type.
#[serde(rename = "state", alias = "status")]
pub state: String,
/// The relay is up, so the session can actually be driven.
#[serde(default)]
pub cdp_ready: bool,
/// `interactive` or `cookie_bot`.
#[serde(default)]
pub kind: Option<String>,
/// Set when this session belongs to a cookie-bot run.
#[serde(default)]
pub run_id: Option<String>,
/// The team the hours are attributed to, when the caller belongs to one.
#[serde(default)]
pub team_id: Option<String>,
#[serde(default)]
pub started_at: Option<String>,
/// When it finished. The backend sends one timestamp, not a
/// `ready_at`/`closed_at` pair.
#[serde(default)]
pub ended_at: Option<String>,
/// Why it ended: `stopped_by_user`, `max_duration`, `lost the profile lock`…
#[serde(default)]
pub close_reason: Option<String>,
/// What it has cost so far. A live session is already being charged, so this
/// is the running wall clock rather than 0 until it closes.
#[serde(default)]
pub billed_seconds: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
struct RemoteSessionListResponse {
#[serde(default)]
sessions: Vec<RemoteSessionState>,
}
/// Every session the caller currently owns.
pub async fn list_remote_sessions() -> Result<Vec<RemoteSessionState>, RemoteSessionError> {
let endpoint = format!("{}/api/remote-sessions", crate::cloud_auth::CLOUD_API_URL);
let response: RemoteSessionListResponse = get_json(endpoint).await?;
Ok(response.sessions)
}
/// One session's real state, for a one-shot read.
///
/// The event stream is how the desktop normally learns a transition; this is
/// for the cases a stream cannot serve — a window opened after the fact, or a
/// reconnect that needs to confirm what it missed.
pub async fn get_remote_session(
session_id: &str,
) -> Result<RemoteSessionState, RemoteSessionError> {
let endpoint = format!(
"{}/api/remote-sessions/{}",
crate::cloud_auth::CLOUD_API_URL,
urlencoding::encode(session_id)
);
get_json(endpoint).await
}
async fn get_json<T: serde::de::DeserializeOwned>(
endpoint: String,
) -> Result<T, RemoteSessionError> {
crate::cloud_auth::CLOUD_AUTH
.api_call_with_retry(|token| {
let endpoint = endpoint.clone();
async move {
let response = reqwest::Client::new()
.get(&endpoint)
.bearer_auth(token)
.send()
.await
.map_err(|e| format!("reach backend: {e}"))?;
let status = response.status().as_u16();
if !(200..300).contains(&status) {
let text = response.text().await.unwrap_or_default();
return Err(format!("({status}) {text}"));
}
response
.json::<T>()
.await
.map_err(|e| format!("decode response: {e}"))
}
})
.await
.map_err(|e| classify_error_string(&e))
}
// --- Live state, without polling -------------------------------------------
/// A session transition. Payload is the session as the backend sees it.
pub const EVENT_SESSION_STATE: &str = "remote-session-state";
/// Everything the caller owns, sent once when the stream connects.
pub const EVENT_SESSION_SNAPSHOT: &str = "remote-session-snapshot";
/// Whether the desktop is currently receiving transitions.
pub const EVENT_STREAM_STATUS: &str = "remote-session-stream";
/// How long a silent stream is trusted before it is treated as dead.
///
/// The backend heartbeats, so silence past this means the socket died without
/// an error — which is exactly what a laptop returning from sleep sees. Held
/// well above the heartbeat interval so a slow network cannot cause a churn of
/// reconnects.
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
/// First reconnect delay. Doubles per failure.
const RECONNECT_BASE: Duration = Duration::from_secs(1);
/// Ceiling on the reconnect delay.
const RECONNECT_MAX: Duration = Duration::from_secs(60);
/// Where the backoff restarts after an auth failure. Being signed out or
/// unentitled is not something a fast retry fixes, and hammering an endpoint
/// that will keep saying no is how a background task becomes a battery bug.
const AUTH_BACKOFF_ATTEMPT: u32 = 6;
/// Granularity of the cancellable sleep, so a shutdown is not held up by a
/// minute-long backoff.
const SHUTDOWN_POLL: Duration = Duration::from_millis(250);
static STREAM_RUNNING: AtomicBool = AtomicBool::new(false);
static STREAM_TASK: Mutex<Option<tauri::async_runtime::JoinHandle<()>>> = Mutex::new(None);
/// One frame off the wire.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SseFrame {
pub id: Option<String>,
pub event: Option<String>,
pub data: String,
}
/// Incremental `text/event-stream` decoder.
///
/// Kept as a value with no IO so the framing rules — multi-line data, the
/// blank-line terminator, comments, CRLF, a chunk boundary landing mid-field —
/// are testable without a server.
#[derive(Debug, Default)]
pub struct SseDecoder {
buffer: Vec<u8>,
event: Option<String>,
data: String,
id: Option<String>,
}
impl SseDecoder {
pub fn new() -> Self {
Self::default()
}
/// Feed bytes, get back whatever frames completed.
pub fn push(&mut self, chunk: &[u8]) -> Vec<SseFrame> {
self.buffer.extend_from_slice(chunk);
let mut frames = Vec::new();
// A newline is never part of a multi-byte UTF-8 sequence, so splitting the
// raw bytes on it cannot cut a character in half.
while let Some(index) = self.buffer.iter().position(|b| *b == b'\n') {
let line: Vec<u8> = self.buffer.drain(..=index).collect();
let line = String::from_utf8_lossy(&line[..line.len() - 1]);
let line = line.strip_suffix('\r').unwrap_or(&line);
if line.is_empty() {
if let Some(frame) = self.take_frame() {
frames.push(frame);
}
continue;
}
// A leading colon is a comment; some proxies keep a stream alive with
// nothing else, so it must not be mistaken for a field.
if line.starts_with(':') {
continue;
}
let (field, value) = match line.split_once(':') {
Some((field, value)) => (field, value.strip_prefix(' ').unwrap_or(value)),
None => (line, ""),
};
match field {
"event" => self.event = Some(value.to_string()),
"id" => self.id = Some(value.to_string()),
"data" => {
if !self.data.is_empty() {
self.data.push('\n');
}
self.data.push_str(value);
}
// `retry` is the server's reconnect hint; this client's own backoff
// already bounds that, so honouring it would only make the interval
// less predictable.
_ => {}
}
}
frames
}
fn take_frame(&mut self) -> Option<SseFrame> {
let event = self.event.take();
let id = self.id.take();
let data = std::mem::take(&mut self.data);
if data.is_empty() && event.is_none() {
return None;
}
Some(SseFrame { id, event, data })
}
}
/// A frame that carries nothing the frontend needs.
fn is_heartbeat(kind: &str) -> bool {
matches!(kind, "heartbeat" | "ping" | "keepalive")
}
/// Turn one decoded frame into the Tauri event and payload it becomes.
///
/// The discriminator lives INSIDE the JSON, not in the SSE `event:` line: Nest
/// only sets `MessageEvent.type` for the heartbeat, so every real frame arrives
/// as the default `message` event carrying
/// `{"type":"snapshot"|"state"|"progress"|"closed","at":…,"sessions"|"session":…}`.
/// Routing on the event name alone emitted that whole envelope as a session, so
/// `profile_id` was always undefined and the frontend dropped every transition
/// — the desktop stayed exactly as blind between launch and stop as it was
/// before the stream existed.
///
/// The SSE name is still honoured when there is one, so a backend that starts
/// naming its frames keeps working without a desktop release.
pub fn route_frame(event: Option<&str>, data: &str) -> Option<(&'static str, serde_json::Value)> {
if let Some(name) = event {
if is_heartbeat(name) {
return None;
}
}
let payload = match serde_json::from_str::<serde_json::Value>(data) {
Ok(value) => value,
Err(e) => {
log::warn!("Ignoring malformed remote-session event: {e}");
return None;
}
};
let object = payload.as_object()?;
let kind = object
.get("type")
.and_then(serde_json::Value::as_str)
.or(event)
.unwrap_or("state");
if is_heartbeat(kind) {
return None;
}
if kind == "snapshot" {
let sessions = object
.get("sessions")
.cloned()
.unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
return Some((
EVENT_SESSION_SNAPSHOT,
serde_json::json!({ "sessions": sessions }),
));
}
// `state`, `progress` and `closed` all wrap one session. A frame that
// carries neither an inner `session` nor a session of its own is not
// something a consumer can apply, and forwarding it is how the envelope bug
// happened in the first place.
if let Some(session) = object.get("session").filter(|v| v.is_object()) {
return Some((EVENT_SESSION_STATE, session.clone()));
}
if object.contains_key("session_id") {
return Some((EVENT_SESSION_STATE, payload));
}
log::warn!("Ignoring remote-session frame with no session: {kind}");
None
}
/// Delay before reconnect attempt `attempt`, doubling to a ceiling.
pub fn reconnect_delay(attempt: u32) -> Duration {
let factor = 1u64.checked_shl(attempt.min(16)).unwrap_or(u64::MAX);
RECONNECT_BASE
.saturating_mul(factor.min(u32::MAX as u64) as u32)
.min(RECONNECT_MAX)
}
/// Start receiving session transitions. Idempotent: a second call while the
/// stream is up is a no-op rather than a second socket.
pub fn start_session_events(app: AppHandle) {
if STREAM_RUNNING.swap(true, Ordering::SeqCst) {
return;
}
let handle = tauri::async_runtime::spawn(async move {
run_session_events(app).await;
});
if let Ok(mut slot) = STREAM_TASK.lock() {
*slot = Some(handle);
}
}
/// Stop receiving. Safe to call when nothing is running.
pub fn stop_session_events() {
if !STREAM_RUNNING.swap(false, Ordering::SeqCst) {
return;
}
if let Ok(mut slot) = STREAM_TASK.lock() {
if let Some(handle) = slot.take() {
handle.abort();
}
}
}
/// Whether the subscriber task is alive.
pub fn session_events_running() -> bool {
STREAM_RUNNING.load(Ordering::SeqCst)
}
async fn run_session_events(app: AppHandle) {
let mut attempt = 0u32;
// Echoed back on reconnect as `Last-Event-ID`, per the SSE spec, IF the
// backend ever labels its frames. It does not today — `stream()` emits no
// `id:` line and keeps no replay buffer — so this stays `None` and nothing is
// resumed. What bounds the loss instead is the stream opening with a full
// snapshot, which re-states every session the caller still owns.
let mut last_event_id: Option<String> = None;
while STREAM_RUNNING.load(Ordering::SeqCst) {
match connect_session_events(last_event_id.as_deref()).await {
Ok(response) => {
attempt = 0;
emit_stream_status(&app, true, None);
match consume_session_events(&app, response, &mut last_event_id).await {
Ok(()) => {
log::info!("Remote session stream closed by the backend");
emit_stream_status(&app, false, None);
}
Err(reason) => {
log::warn!("Remote session stream ended: {reason}");
emit_stream_status(&app, false, Some(&reason));
}
}
}
Err(err) => {
let reason = err.to_string();
if matches!(err, RemoteSessionError::NotAuthorised(_)) {
attempt = attempt.max(AUTH_BACKOFF_ATTEMPT);
}
log::warn!("Remote session stream could not connect: {reason}");
emit_stream_status(&app, false, Some(&reason));
}
}
if !STREAM_RUNNING.load(Ordering::SeqCst) {
break;
}
let delay = jittered(reconnect_delay(attempt));
attempt = attempt.saturating_add(1);
sleep_unless_stopped(delay).await;
}
log::info!("Remote session stream stopped");
}
/// Spread reconnects so every desktop that lost the same backend does not come
/// back in the same millisecond.
fn jittered(delay: Duration) -> Duration {
use rand::RngExt;
let factor = rand::rng().random_range(0.8f64..1.2f64);
delay.mul_f64(factor)
}
async fn sleep_unless_stopped(total: Duration) {
let mut slept = Duration::ZERO;
while slept < total && STREAM_RUNNING.load(Ordering::SeqCst) {
let step = SHUTDOWN_POLL.min(total - slept);
tokio::time::sleep(step).await;
slept += step;
}
}
/// The stream's own HTTP client.
///
/// Deliberately not the shared one: a total request timeout would kill a
/// healthy stream on schedule, so only the connect phase is bounded and
/// liveness is enforced by the idle timeout instead.
fn stream_client() -> &'static reqwest::Client {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
}
async fn connect_session_events(
last_event_id: Option<&str>,
) -> Result<reqwest::Response, RemoteSessionError> {
let endpoint = format!(
"{}/api/remote-sessions/events",
crate::cloud_auth::CLOUD_API_URL
);
crate::cloud_auth::CLOUD_AUTH
.api_call_with_retry(|token| {
let endpoint = endpoint.clone();
let resume_from = last_event_id.map(str::to_string);
async move {
// Going through api_call_with_retry means a token that expired during
// a long stream is refreshed on the reconnect instead of turning a
// signed-in desktop into a permanently silent one.
let mut request = stream_client()
.get(&endpoint)
.bearer_auth(token)
.header(reqwest::header::ACCEPT, "text/event-stream")
.header(reqwest::header::CACHE_CONTROL, "no-cache");
if let Some(id) = resume_from {
request = request.header("Last-Event-ID", id);
}
let response = request
.send()
.await
.map_err(|e| format!("reach backend: {e}"))?;
let status = response.status().as_u16();
if !(200..300).contains(&status) {
let text = response.text().await.unwrap_or_default();
return Err(format!("({status}) {text}"));
}
Ok(response)
}
})
.await
.map_err(|e| classify_error_string(&e))
}
async fn consume_session_events(
app: &AppHandle,
response: reqwest::Response,
last_event_id: &mut Option<String>,
) -> Result<(), String> {
use futures_util::StreamExt;
let mut stream = response.bytes_stream();
let mut decoder = SseDecoder::new();
loop {
if !STREAM_RUNNING.load(Ordering::SeqCst) {
return Ok(());
}
let next = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await;
let chunk = match next {
// No heartbeat. The socket is gone even though nothing errored, which
// is what a machine returning from sleep sees.
Err(_) => return Err("no heartbeat within the idle timeout".to_string()),
Ok(None) => return Ok(()),
Ok(Some(Err(e))) => return Err(format!("stream error: {e}")),
Ok(Some(Ok(bytes))) => bytes,
};
for frame in decoder.push(&chunk) {
if let Some(id) = &frame.id {
*last_event_id = Some(id.clone());
}
dispatch_frame(app, &frame);
}
}
}
fn dispatch_frame(app: &AppHandle, frame: &SseFrame) {
let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) else {
return;
};
use tauri::Emitter;
if let Err(e) = app.emit(target, payload) {
log::warn!("Failed to emit {target}: {e}");
}
}
fn emit_stream_status(app: &AppHandle, connected: bool, reason: Option<&str>) {
use tauri::Emitter;
let payload = serde_json::json!({ "connected": connected, "reason": reason });
if let Err(e) = app.emit(EVENT_STREAM_STATUS, payload) {
log::warn!("Failed to emit {EVENT_STREAM_STATUS}: {e}");
}
RemoteSessionError::Other(message.to_string())
}
#[cfg(test)]
@@ -306,4 +837,290 @@ mod tests {
assert_ne!(a, idempotency_key("p1", "attempt-2"));
assert_ne!(a, idempotency_key("p2", "attempt-1"));
}
#[test]
fn a_typed_failure_becomes_a_code_the_frontend_can_translate() {
// The variants carry the backend's English. Surfacing that verbatim is
// how an untranslated string reaches a Russian user.
let busy = RemoteSessionError::NoCapacity("no macos host free".to_string());
assert_eq!(busy.to_error_json(), r#"{"code":"REMOTE_NO_CAPACITY"}"#);
let taken = RemoteSessionError::Conflict("profile already has a live session".to_string());
assert_eq!(
taken.to_error_json(),
r#"{"code":"REMOTE_SESSION_CONFLICT"}"#
);
}
#[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
// — "you are out of hours" and "the fleet is full" are both refusals but
// only one of them is worth retrying.
let err =
classify_error_string(r#"(403) {"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"used":200}"#);
let json: serde_json::Value =
serde_json::from_str(&err.to_error_json()).expect("valid envelope");
assert_eq!(json["code"], "REMOTE_HOURS_EXHAUSTED");
assert_eq!(json["params"]["granted"], "200");
}
/// A verbatim `RemoteSessionView`, field for field, as `toView` in
/// donutbrowser-infra's `remote-sessions.service.ts` builds it.
///
/// Hand-written JSON is what let this type declare `status`, `ready_at` and
/// `closed_at` while the backend sent `state` and `ended_at`: the test agreed
/// with the type and neither agreed with the server, so every list and single
/// read failed to decode in production and passed in CI.
const SERVER_SESSION_VIEW: &str = r#"{
"session_id":"sess-1","profile_id":"p1","platform":"macos","kind":"cookie_bot",
"run_id":"r1","team_id":"t1","state":"live","cdp_ready":true,
"started_at":"2026-08-03T00:00:00.000Z","ended_at":null,
"billed_seconds":1830,"close_reason":null
}"#;
#[test]
fn the_session_state_payload_matches_what_the_backend_sends() {
// The desktop has been blind between launch and stop; every field here is
// one it could previously only learn by reading the production database.
let state: RemoteSessionState = serde_json::from_str(SERVER_SESSION_VIEW)
.expect("the backend's session payload must deserialize");
assert_eq!(state.state, "live");
assert!(state.cdp_ready);
assert_eq!(state.run_id.as_deref(), Some("r1"));
assert_eq!(state.team_id.as_deref(), Some("t1"));
assert_eq!(state.kind.as_deref(), Some("cookie_bot"));
assert_eq!(state.billed_seconds, Some(1830));
assert!(state.ended_at.is_none());
}
#[test]
fn a_list_response_of_real_server_views_decodes() {
// `list_remote_sessions` is the fallback for everything the stream cannot
// serve. It returned Err("decode response: missing field `status`") on
// every call for as long as this type disagreed with `toView`.
let body = format!(r#"{{"sessions":[{SERVER_SESSION_VIEW}]}}"#);
let response: RemoteSessionListResponse =
serde_json::from_str(&body).expect("the backend's list payload must deserialize");
assert_eq!(response.sessions.len(), 1);
assert_eq!(response.sessions[0].state, "live");
}
#[test]
fn the_older_status_key_from_the_launch_reply_still_decodes() {
// `POST /api/remote-sessions` predates the reconciled vocabulary and
// answers `status`. One type reads both rather than two types drifting.
let state: RemoteSessionState =
serde_json::from_str(r#"{"session_id":"s1","status":"provisioning"}"#)
.expect("a fresh session must deserialize");
assert_eq!(state.state, "provisioning");
assert!(!state.cdp_ready);
assert!(state.platform.is_none());
}
#[test]
fn a_close_transition_carries_what_the_session_cost() {
let state: RemoteSessionState = serde_json::from_str(
r#"{"session_id":"s1","state":"closed","close_reason":"stopped_by_user",
"ended_at":"2026-08-03T01:30:00.000Z","billed_seconds":1830}"#,
)
.expect("a close payload must deserialize");
assert_eq!(state.billed_seconds, Some(1830));
assert_eq!(state.close_reason.as_deref(), Some("stopped_by_user"));
assert!(state.ended_at.is_some());
}
#[test]
fn an_error_state_decodes_rather_than_being_treated_as_unknown() {
// `error` is one of the five states the backend reconciles to. A session
// that failed on the fleet must reach the UI as itself.
let state: RemoteSessionState =
serde_json::from_str(r#"{"session_id":"s1","state":"error","close_reason":"agent_lost"}"#)
.expect("an error payload must deserialize");
assert_eq!(state.state, "error");
}
#[test]
fn the_decoder_reads_a_whole_frame() {
let mut decoder = SseDecoder::new();
let frames = decoder.push(b"event: session\nid: 7\ndata: {\"status\":\"ready\"}\n\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].event.as_deref(), Some("session"));
assert_eq!(frames[0].id.as_deref(), Some("7"));
assert_eq!(frames[0].data, r#"{"status":"ready"}"#);
}
#[test]
fn a_frame_split_across_chunks_is_not_lost() {
// TCP does not respect frame boundaries. Dropping a half-arrived frame
// would silently lose the transition that says the browser is ready.
let mut decoder = SseDecoder::new();
assert!(decoder.push(b"event: session\ndata: {\"sta").is_empty());
assert!(decoder.push(b"tus\":\"live\"}").is_empty());
let frames = decoder.push(b"\n\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, r#"{"status":"live"}"#);
}
#[test]
fn several_frames_in_one_chunk_all_arrive() {
let mut decoder = SseDecoder::new();
let frames = decoder.push(b"data: 1\n\ndata: 2\n\ndata: 3\n\n");
let payloads: Vec<&str> = frames.iter().map(|f| f.data.as_str()).collect();
assert_eq!(payloads, vec!["1", "2", "3"]);
}
#[test]
fn comments_and_crlf_framing_do_not_produce_phantom_events() {
// A proxy that keeps the connection warm with `:` lines must not look
// like a stream of empty transitions.
let mut decoder = SseDecoder::new();
let frames = decoder.push(b": keep-alive\r\n\r\ndata: {}\r\n\r\n");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, "{}");
}
#[test]
fn multi_line_data_is_rejoined_with_newlines() {
let mut decoder = SseDecoder::new();
let frames = decoder.push(b"data: {\ndata: \"a\": 1\ndata: }\n\n");
assert_eq!(frames[0].data, "{\n\"a\": 1\n}");
}
/// Route whatever the decoder makes of a literal wire capture, so the test
/// exercises the same two steps production does.
fn route_wire(bytes: &[u8]) -> Vec<(&'static str, serde_json::Value)> {
let mut decoder = SseDecoder::new();
decoder
.push(bytes)
.iter()
.filter_map(|frame| route_frame(frame.event.as_deref(), &frame.data))
.collect()
}
#[test]
fn a_heartbeat_is_not_forwarded_to_the_frontend() {
// Emitting one would make every consumer re-render twice a minute for
// nothing. Nest names this one, so it arrives with an `event:` line.
assert!(route_wire(b"event: ping\ndata: {}\n\n").is_empty());
assert!(route_wire(b"event: heartbeat\ndata: {}\n\n").is_empty());
// And the same frame with the discriminator inside the JSON instead.
assert!(
route_wire(b"data: {\"type\":\"ping\",\"at\":\"2026-08-03T00:00:00.000Z\"}\n\n").is_empty()
);
}
#[test]
fn the_opening_snapshot_reaches_the_snapshot_event() {
// Byte-for-byte what Nest writes for `{type:'snapshot',at,sessions}`: no
// `event:` line, because the controller only sets MessageEvent.type for the
// ping. Routing on the event NAME sent this to `remote-session-state` as a
// raw envelope, so `remote-session-snapshot` was never emitted at all and
// the live view started empty and stayed empty.
let routed = route_wire(
b"data: {\"type\":\"snapshot\",\"at\":\"2026-08-03T00:00:00.000Z\",\"sessions\":[{\"session_id\":\"s1\",\"profile_id\":\"p1\",\"state\":\"live\"}]}\n\n",
);
assert_eq!(routed.len(), 1);
assert_eq!(routed[0].0, EVENT_SESSION_SNAPSHOT);
assert_eq!(routed[0].1["sessions"][0]["profile_id"], "p1");
}
#[test]
fn a_transition_is_unwrapped_to_the_session_the_frontend_indexes_by() {
// The consumer keys `liveSessions` by `profile_id`. Emitting the envelope
// meant every frame hit `if (!session.profile_id) return;` and a live run
// showed as idle for its whole duration.
for kind in ["state", "progress", "closed"] {
let wire = format!(
"data: {{\"type\":\"{kind}\",\"at\":\"2026-08-03T00:00:00.000Z\",\"session\":{{\"session_id\":\"s1\",\"profile_id\":\"p1\",\"state\":\"live\",\"cdp_ready\":true}}}}\n\n"
);
let routed = route_wire(wire.as_bytes());
assert_eq!(routed.len(), 1, "{kind} must produce one event");
assert_eq!(routed[0].0, EVENT_SESSION_STATE);
assert_eq!(routed[0].1["profile_id"], "p1", "{kind} must be unwrapped");
assert_eq!(routed[0].1["state"], "live");
// And the payload must deserialize as the type the one-shot reads use.
let session: RemoteSessionState = serde_json::from_value(routed[0].1.clone())
.expect("a streamed session must decode as RemoteSessionState");
assert_eq!(session.session_id, "s1");
}
}
#[test]
fn a_named_event_carrying_a_bare_session_is_still_routed() {
// If the backend starts labelling its frames and drops the envelope, the
// desktop must not need a release to keep working.
let routed = route_wire(
b"event: state\ndata: {\"session_id\":\"s1\",\"profile_id\":\"p1\",\"state\":\"ready\"}\n\n",
);
assert_eq!(routed.len(), 1);
assert_eq!(routed[0].0, EVENT_SESSION_STATE);
assert_eq!(routed[0].1["profile_id"], "p1");
let snapshot =
route_wire(b"event: snapshot\ndata: {\"type\":\"snapshot\",\"sessions\":[]}\n\n");
assert_eq!(snapshot[0].0, EVENT_SESSION_SNAPSHOT);
}
#[test]
fn a_frame_carrying_no_session_is_dropped_rather_than_emitted_raw() {
// Forwarding an envelope the consumer cannot apply is exactly the bug this
// routing exists to close; a malformed frame must be silent, not wrong.
assert!(
route_wire(b"data: {\"type\":\"state\",\"at\":\"2026-08-03T00:00:00.000Z\"}\n\n").is_empty()
);
assert!(route_wire(b"data: not json\n\n").is_empty());
assert!(route_wire(b"data: []\n\n").is_empty());
}
#[test]
fn reconnect_backs_off_and_stops_growing() {
assert_eq!(reconnect_delay(0), Duration::from_secs(1));
assert_eq!(reconnect_delay(3), Duration::from_secs(8));
// A backend that is down for an hour must not be probed thousands of
// times, nor overflow the shift.
assert_eq!(reconnect_delay(20), RECONNECT_MAX);
assert_eq!(reconnect_delay(u32::MAX), RECONNECT_MAX);
}
#[test]
fn the_auth_backoff_floor_is_far_longer_than_the_first_retry() {
// Being signed out or unentitled is not fixed by retrying in a second.
assert!(reconnect_delay(AUTH_BACKOFF_ATTEMPT) >= Duration::from_secs(30));
}
#[test]
fn jitter_stays_within_a_fifth_of_the_delay() {
let base = Duration::from_secs(10);
for _ in 0..64 {
let delay = jittered(base);
assert!(
delay >= Duration::from_secs(8) && delay <= Duration::from_secs(12),
"jitter escaped its bounds: {delay:?}"
);
}
}
#[tokio::test]
async fn a_stopped_stream_does_not_wait_out_its_backoff() {
// A minute-long backoff must not hold up app shutdown.
STREAM_RUNNING.store(true, Ordering::SeqCst);
let started = std::time::Instant::now();
let sleeper = tokio::spawn(sleep_unless_stopped(Duration::from_secs(60)));
tokio::time::sleep(Duration::from_millis(50)).await;
STREAM_RUNNING.store(false, Ordering::SeqCst);
sleeper.await.expect("the sleep task must finish");
assert!(
started.elapsed() < Duration::from_secs(5),
"shutdown waited on the backoff"
);
}
#[test]
fn stopping_a_stream_that_never_started_is_harmless() {
STREAM_RUNNING.store(false, Ordering::SeqCst);
stop_session_events();
assert!(!session_events_running());
}
}
+6
View File
@@ -3321,6 +3321,12 @@ pub async fn set_profile_sync_mode(
.save_profile(&profile)
.map_err(|e| format!("Failed to save profile: {e}"))?;
// The bot materialises the profile from donut-sync, so switching sync off (or
// to Encrypted, which the host cannot decrypt) is a refusal reason. The server
// holds only the copy this machine declared; without this, an enrolment keeps
// claiming a syncable profile every night after the user turned sync off.
crate::cookie_bot::report_profile_state(&profile);
let _ = events::emit("profiles-changed", ());
// When (re-)enabling sync, clear any stale tombstone from a previous
+18
View File
@@ -72,6 +72,24 @@ impl SyncScheduler {
self.running.store(false, Ordering::SeqCst);
}
/// Whether this specific profile is mid-sync or queued to sync.
///
/// A remote host materialises the profile by pulling the synced manifest, so
/// launching one while the upload is still running hands it a torn snapshot:
/// the manifest is written last, but a launch that races a *queued* sync can
/// still pull files that are about to be replaced. Either way the browser
/// comes up on a profile that never existed on this machine.
///
/// Deliberately per-profile rather than the global
/// {@link Self::is_sync_in_progress}: an unrelated profile uploading 80 MB
/// must not block launching this one.
pub async fn is_profile_sync_in_progress(&self, profile_id: &str) -> bool {
if self.in_flight_profiles.lock().await.contains(profile_id) {
return true;
}
self.pending_profiles.lock().await.contains_key(profile_id)
}
/// Check if any sync operation is currently in progress
pub async fn is_sync_in_progress(&self) -> bool {
let in_flight = self.in_flight_profiles.lock().await;
+49 -1
View File
@@ -18,6 +18,7 @@ import {
ConsistencyWarningDialog,
isConsistencyWarningSuppressed,
} from "@/components/consistency-warning-dialog";
import { CookieBotPage, type CookieBotTab } from "@/components/cookie-bot-page";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
@@ -55,6 +56,7 @@ import { WindowResizeWarningDialog } from "@/components/window-resize-warning-di
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { useGroupEvents } from "@/hooks/use-group-events";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
@@ -66,7 +68,7 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { getEntitlements } from "@/lib/entitlements";
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
import { MOTION_EASE_OUT } from "@/lib/motion";
import {
ONBOARDING_TOUR_CLOSED_EVENT,
@@ -253,6 +255,14 @@ export default function Home() {
// /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop
// actions disabled with a Pro badge.
const automationUnlocked = getEntitlements(cloudUser).browserAutomation;
// The rail needs to show a live run from every page, so the shell subscribes
// to the shared cookie-bot store too. It is a module singleton, so this costs
// one more listener and no extra request. This is also what starts the event
// stream for a user who signs in without restarting the app.
const { liveSessions: cookieBotLiveSessions } = useCookieBot(
canUseCookieBot(cloudUser),
cookieBotScopeFor(cloudUser),
);
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
useState(false);
@@ -283,6 +293,9 @@ export default function Home() {
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
"api" | "mcp"
>("api");
const [cookieBotDialogOpen, setCookieBotDialogOpen] = useState(false);
const [cookieBotInitialTab, setCookieBotInitialTab] =
useState<CookieBotTab>("overview");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
@@ -379,6 +392,7 @@ export default function Home() {
setIntegrationsDialogOpen(false);
setImportProfileDialogOpen(false);
setAccountDialogOpen(false);
setCookieBotDialogOpen(false);
setCurrentPage(page);
switch (page) {
@@ -397,6 +411,9 @@ export default function Home() {
case "groups":
setGroupManagementDialogOpen(true);
break;
case "cookieBot":
setCookieBotDialogOpen(true);
break;
case "integrations":
setIntegrationsDialogOpen(true);
break;
@@ -462,6 +479,19 @@ export default function Home() {
case "goGroups":
handleRailNavigate("groups");
break;
case "goCookieBot": {
// Mod+B: navigate first time; flip overview↔activity while already
// there, matching how Mod+I flips the integrations tabs.
if (currentPage === "cookieBot") {
setCookieBotInitialTab((cur) =>
cur === "overview" ? "activity" : "overview",
);
} else {
setCookieBotInitialTab("overview");
handleRailNavigate("cookieBot");
}
break;
}
case "goIntegrations": {
// Mod+I: flip api↔mcp tab when already on integrations.
if (currentPage === "integrations") {
@@ -1636,6 +1666,7 @@ export default function Home() {
onOpenAbout={() => {
setAboutDialogOpen(true);
}}
cookieBotRunning={Object.keys(cookieBotLiveSessions).length > 0}
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
{currentPage === "profiles" && (
@@ -1666,6 +1697,7 @@ export default function Home() {
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
onAssignProfilesToGroup={handleAssignProfilesToGroup}
onAssignProfilesToProxy={handleAssignProfilesToProxy}
selectedGroupId={selectedGroupId}
selectedProfiles={selectedProfiles}
onSelectedProfilesChange={setSelectedProfiles}
@@ -1786,6 +1818,22 @@ export default function Home() {
/>
)}
{cookieBotDialogOpen && (
<CookieBotPage
isOpen={cookieBotDialogOpen}
onClose={() => {
setCookieBotDialogOpen(false);
setCurrentPage("profiles");
}}
subPage={currentPage === "cookieBot"}
initialTab={cookieBotInitialTab}
profiles={profiles}
cloudUser={cloudUser}
onOpenProfileSync={handleOpenProfileSyncDialog}
onAssignProxy={handleAssignProfilesToProxy}
/>
)}
{accountDialogOpen && (
<AccountPage
isOpen={accountDialogOpen}
+100 -2
View File
@@ -11,7 +11,13 @@ import {
LuRefreshCw,
LuUser,
} from "react-icons/lu";
import {
formatDate,
formatHours,
RemoteHoursMeter,
} from "@/components/cookie-bot-shared";
import { LoadingButton } from "@/components/loading-button";
import { TeamUsagePanel } from "@/components/team-usage-panel";
import {
AnimatedTabs,
AnimatedTabsContent,
@@ -24,8 +30,13 @@ import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { translateBackendError } from "@/lib/backend-errors";
import { getEntitlements } from "@/lib/entitlements";
import {
canUseCookieBot,
getEntitlements,
isTeamOwner,
} from "@/lib/entitlements";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { SyncSettings } from "@/types";
@@ -56,6 +67,25 @@ export function AccountPage({
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
// Remote hours are plan truth, so they belong here rather than only next to
// the controls that spend them. Until this landed, `remote-sessions/quota`
// had no caller anywhere and a customer's first sight of their allowance was
// a refused launch.
const remoteHoursVisible = isLoggedIn && canUseCookieBot(user);
const showTeamUsage = remoteHoursVisible && isTeamOwner(user);
const { quota, isLoading: isQuotaLoading } = useCookieBot(
remoteHoursVisible,
cookieBotScopeFor(user),
);
const [activeTab, setActiveTab] = useState("account");
// Signing out (or losing the team) removes the tab while it is the selected
// one, which would leave the page showing an empty panel with no trigger to
// click back to.
useEffect(() => {
if (!showTeamUsage && activeTab === "team-usage") setActiveTab("account");
}, [showTeamUsage, activeTab]);
// Self-hosted server state. Loaded once when the dialog opens and persisted
// via `save_sync_settings` so the rest of the app picks up the new URL/token
// from `SettingsManager`.
@@ -201,11 +231,16 @@ export function AccountPage({
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
<AnimatedTabs defaultValue="account">
<AnimatedTabs value={activeTab} onValueChange={setActiveTab}>
<AnimatedTabsList>
<AnimatedTabsTrigger value="account">
{t("account.tabs.account")}
</AnimatedTabsTrigger>
{showTeamUsage && (
<AnimatedTabsTrigger value="team-usage">
{t("account.tabs.teamUsage")}
</AnimatedTabsTrigger>
)}
<AnimatedTabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
@@ -251,6 +286,63 @@ export function AccountPage({
</div>
</div>
{remoteHoursVisible && (
// A headline block, not one field among six: the allowance
// is the number a customer needs before a launch is
// refused, which is the only way they ever saw it before.
<div className="rounded-md border border-border bg-muted/40 px-3 py-2.5">
<div className="flex items-baseline justify-between gap-3">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("cookieBot.hours.label")}
</p>
{formatDate(quota?.period_end) && (
<p className="text-xs tabular-nums text-muted-foreground">
{t("cookieBot.hours.resets", {
date: formatDate(quota?.period_end),
})}
</p>
)}
</div>
<p className="mt-1 text-lg leading-none font-semibold tabular-nums">
{quota ? formatHours(quota.remaining_hours) : "—"}
<span className="ml-1 text-sm font-normal text-muted-foreground">
{t("cookieBot.hours.remainingOf", {
total: quota
? formatHours(quota.granted_hours)
: "—",
})}
</span>
</p>
<RemoteHoursMeter
quota={quota}
isLoading={isQuotaLoading}
variant="inline"
className="mt-2"
/>
<div className="mt-2 flex items-baseline justify-between gap-3">
<p className="text-xs tabular-nums text-muted-foreground">
{t("cookieBot.hours.used", {
used: quota ? formatHours(quota.used_hours) : "—",
total: quota
? formatHours(quota.granted_hours)
: "—",
})}
</p>
{showTeamUsage && (
<button
type="button"
onClick={() => {
setActiveTab("team-usage");
}}
className="text-xs text-muted-foreground underline underline-offset-2 transition-colors duration-100 hover:text-foreground"
>
{t("account.viewTeamUsage")}
</button>
)}
</div>
</div>
)}
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
@@ -362,6 +454,12 @@ export function AccountPage({
</div>
</AnimatedTabsContent>
{showTeamUsage && (
<AnimatedTabsContent value="team-usage" className="mt-4">
<TeamUsagePanel quota={quota} />
</AnimatedTabsContent>
)}
<AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is
+2
View File
@@ -8,6 +8,7 @@ import {
LuBadgeInfo,
LuCircleStop,
LuCloud,
LuCookie,
LuInfo,
LuKeyboard,
LuPlay,
@@ -67,6 +68,7 @@ const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
goProxies: FiWifi,
goExtensions: LuPuzzle,
goGroups: LuUsers,
goCookieBot: LuCookie,
goIntegrations: LuPlug,
goAccount: LuCloud,
goSettings: GoGear,
+583
View File
@@ -0,0 +1,583 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuSearch } from "react-icons/lu";
import {
formatDateTime,
formatDuration,
formatElapsed,
hasRunCounters,
indexProfiles,
indexRunsById,
indexRunsBySession,
outcomeLabel,
parseIso,
runStatusLabel,
runStatusTone,
StatusDot,
sessionCloseReason,
sessionDisplayName,
sessionElapsedSeconds,
sessionPhaseLabel,
sessionTone,
useSecondTicker,
} from "@/components/cookie-bot-shared";
import { Button } from "@/components/ui/button";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { translateBackendError } from "@/lib/backend-errors";
import { type CookieBotRun, cancelCookieBotRun } from "@/lib/cookie-bot";
import { MOTION_EASE_OUT } from "@/lib/motion";
import {
type RemoteSessionState,
stopRemoteSession,
} from "@/lib/remote-sessions";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile } from "@/types";
export type RunFilter = "all" | "succeeded" | "partial" | "failed";
interface CookieBotActivityProps {
live: RemoteSessionState[];
streamConnected: boolean;
runs: CookieBotRun[];
isLoading: boolean;
profiles: BrowserProfile[];
showOperator: boolean;
filter: RunFilter;
onFilterChange: (filter: RunFilter) => void;
onRefresh: () => void;
}
export function CookieBotActivity({
live,
streamConnected,
runs,
isLoading,
profiles,
showOperator,
filter,
onFilterChange,
onRefresh,
}: CookieBotActivityProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const profileIndex = useMemo(() => indexProfiles(profiles), [profiles]);
const runsBySession = useMemo(() => indexRunsBySession(runs), [runs]);
const runsById = useMemo(() => indexRunsById(runs), [runs]);
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
return runs.filter((run) => {
if (filter !== "all" && run.status !== filter) return false;
if (!needle) return true;
const name = run.profile_name ?? profileIndex.get(run.profile_id)?.name;
return (
(name ?? "").toLowerCase().includes(needle) ||
(run.email ?? "").toLowerCase().includes(needle)
);
});
}, [runs, filter, search, profileIndex]);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<LiveSessions
live={live}
streamConnected={streamConnected}
profileIndex={profileIndex}
runsBySession={runsBySession}
runsById={runsById}
onChanged={onRefresh}
/>
<div className="flex shrink-0 items-center gap-2">
<div className="relative min-w-0 flex-1">
<LuSearch className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
className="h-8 pl-8 text-sm"
placeholder={t("cookieBot.history.searchPlaceholder")}
/>
</div>
<Select
value={filter}
onValueChange={(value) => {
onFilterChange(value as RunFilter);
}}
>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("cookieBot.history.filterAll")}
</SelectItem>
<SelectItem value="succeeded">
{t("cookieBot.history.filterComplete")}
</SelectItem>
<SelectItem value="partial">
{t("cookieBot.history.filterPartial")}
</SelectItem>
<SelectItem value="failed">
{t("cookieBot.history.filterFailed")}
</SelectItem>
</SelectContent>
</Select>
</div>
<FadingScrollArea
className="min-h-0 flex-1"
style={{ "--scroll-fade-top-offset": "32px" } as React.CSSProperties}
>
<Table
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead className="w-40">
{t("cookieBot.history.columnStarted")}
</TableHead>
<TableHead className="max-w-0">
{t("cookieBot.history.columnProfile")}
</TableHead>
<TableHead className="hidden w-24 @2xl:table-cell">
{t("cookieBot.history.columnDuration")}
</TableHead>
<TableHead className="hidden w-20 text-right @3xl:table-cell">
{t("cookieBot.history.columnSites")}
</TableHead>
<TableHead className="w-32">
{t("cookieBot.history.columnStatus")}
</TableHead>
{showOperator && (
<TableHead className="hidden max-w-0 @4xl:table-cell">
{t("cookieBot.history.columnOperator")}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{isLoading && runs.length === 0 ? (
Array.from({ length: 6 }, (_, i) => (
<TableRow key={`skeleton-${i}`}>
<TableCell colSpan={showOperator ? 6 : 5}>
<div className="flex items-center gap-3">
<Skeleton className="h-3 w-28" />
<Skeleton
className="h-3"
style={{ width: `${30 + ((i * 17) % 40)}%` }}
/>
<div className="flex-1" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
</TableCell>
</TableRow>
))
) : filtered.length === 0 ? (
<TableRow className="border-0! hover:bg-transparent">
<TableCell colSpan={showOperator ? 6 : 5} className="py-16">
<p className="text-center text-sm text-muted-foreground">
{runs.length === 0
? t("cookieBot.history.empty")
: t("cookieBot.history.noMatch")}
</p>
</TableCell>
</TableRow>
) : (
filtered.map((run) => (
<RunRow
key={run.id}
run={run}
profileName={
run.profile_name ??
profileIndex.get(run.profile_id)?.name ??
null
}
showOperator={showOperator}
/>
))
)}
</TableBody>
</Table>
</FadingScrollArea>
</div>
);
}
function RunRow({
run,
profileName,
showOperator,
}: {
run: CookieBotRun;
profileName: string | null;
showOperator: boolean;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const reduceMotion = useReducedMotion();
const started = parseIso(run.started_at);
const ended = parseIso(run.ended_at);
const durationSeconds =
started && ended
? Math.max(0, Math.floor((ended.getTime() - started.getTime()) / 1000))
: run.billed_seconds > 0
? run.billed_seconds
: null;
const countersKnown = hasRunCounters(run);
const hasDetail = Boolean(run.outcome_code) || run.sites_failed > 0;
return (
<>
<TableRow
className={cn("hover:bg-muted/30", hasDetail && "cursor-pointer")}
onClick={() => {
if (hasDetail) setExpanded((open) => !open);
}}
>
<TableCell className="tabular-nums text-muted-foreground">
{formatDateTime(run.started_at ?? run.scheduled_for) ?? "—"}
</TableCell>
<TableCell className="max-w-0 truncate">
{profileName ?? t("cookieBot.history.unknownProfile")}
</TableCell>
<TableCell className="hidden tabular-nums @2xl:table-cell">
{durationSeconds === null ? "—" : formatDuration(t, durationSeconds)}
</TableCell>
{/* An em dash, not a confident `0/12`: the counters are not written
until the fleet's figures are ingested, and printing the column
default as a fact tells a paying user their run did nothing. */}
<TableCell className="hidden text-right tabular-nums text-muted-foreground @3xl:table-cell">
{!countersKnown ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default"></span>
</TooltipTrigger>
<TooltipContent>
{t("cookieBot.history.sitesUnknown")}
</TooltipContent>
</Tooltip>
) : run.sites_total > 0 ? (
`${run.sites_visited}/${run.sites_total}`
) : (
String(run.sites_visited)
)}
</TableCell>
<TableCell>
<span className="flex items-center gap-2 text-xs">
<StatusDot tone={runStatusTone(run.status)} />
{runStatusLabel(t, run.status)}
</span>
</TableCell>
{showOperator && (
<TableCell className="hidden max-w-0 truncate text-muted-foreground @4xl:table-cell">
{run.email ?? "—"}
</TableCell>
)}
</TableRow>
{hasDetail && (
<TableRow className="border-0! hover:bg-transparent">
<TableCell
colSpan={showOperator ? 6 : 5}
className={cn("p-0", !expanded && "border-0!")}
>
<AnimatePresence initial={false}>
{expanded && (
<motion.div
initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
transition={{
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
}}
className="flex flex-col gap-1 px-2 pb-3 text-xs text-muted-foreground"
>
{run.outcome_code && (
<span>
{t("cookieBot.history.outcome", {
reason: outcomeLabel(t, run.outcome_code) ?? "",
})}
</span>
)}
{run.sites_failed > 0 && (
<span>
{t("cookieBot.history.sitesFailed", {
count: run.sites_failed,
})}
</span>
)}
{run.consent_dismissed > 0 && (
<span>
{t("cookieBot.history.consentHandled", {
count: run.consent_dismissed,
})}
</span>
)}
</motion.div>
)}
</AnimatePresence>
</TableCell>
</TableRow>
)}
</>
);
}
/* -------------------------------------------------------------------------- */
/* Live */
/* -------------------------------------------------------------------------- */
function LiveSessions({
live,
streamConnected,
profileIndex,
runsBySession,
runsById,
onChanged,
}: {
live: RemoteSessionState[];
streamConnected: boolean;
profileIndex: Map<string, BrowserProfile>;
runsBySession: Map<string, CookieBotRun>;
runsById: Map<string, CookieBotRun>;
onChanged: () => void;
}) {
const { t } = useTranslation();
const now = useSecondTicker(live.length > 0);
if (live.length === 0) {
return (
<div className="flex shrink-0 items-center gap-2 rounded-md border border-border bg-card px-3 py-2.5">
<StatusDot tone="muted" />
<span className="text-sm text-muted-foreground">
{streamConnected
? t("cookieBot.live.idle")
: t("cookieBot.live.streamOffline")}
</span>
</div>
);
}
return (
<div className="flex shrink-0 flex-col gap-2">
{!streamConnected && (
<p className="text-xs text-warning-text">
{t("cookieBot.live.streamOfflineDetail")}
</p>
)}
{live.map((session) => (
<LiveSessionRow
key={session.session_id}
session={session}
now={now}
name={sessionDisplayName(
session,
profileIndex,
session.run_id
? runsById.get(session.run_id)
: runsBySession.get(session.session_id),
)}
run={
session.run_id
? runsById.get(session.run_id)
: runsBySession.get(session.session_id)
}
onChanged={onChanged}
/>
))}
</div>
);
}
function LiveSessionRow({
session,
now,
name,
run,
onChanged,
}: {
session: RemoteSessionState;
now: number;
name: string | null;
run: CookieBotRun | undefined;
onChanged: () => void;
}) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const [isStopping, setIsStopping] = useState(false);
const elapsed = sessionElapsedSeconds(session, now);
const phase = sessionPhaseLabel(t, session);
const tone = sessionTone(session);
const closeReason = sessionCloseReason(t, session);
// Only once the backend has actually written a counter. Until then the bar
// sat at zero for the whole run and read as "nothing is happening".
const countersKnown = run ? hasRunCounters(run) : false;
const total = run?.sites_total ?? 0;
const visited = run?.sites_visited ?? 0;
const progress =
countersKnown && total > 0 ? Math.min(1, visited / total) : null;
// A night longer than one session's cap is split into chunks, and the run row
// is the only place that can say which one is running. `chunk_index` counts
// chunks STARTED — the server bumps it as it launches each one and treats 0
// as "never got going" — so it already reads as a 1-based position and must
// not be incremented again.
const chunks =
run && run.chunks_total > 1 && run.chunk_index > 0
? t("cookieBot.live.chunk", {
index: Math.min(run.chunk_index, run.chunks_total),
total: run.chunks_total,
})
: null;
const stop = useCallback(async () => {
setIsStopping(true);
try {
if (session.run_id) {
await cancelCookieBotRun(session.run_id);
} else {
await stopRemoteSession(session.session_id);
}
showSuccessToast(t("cookieBot.running.stopped"));
onChanged();
} catch (error) {
showErrorToast(translateBackendError(t, error));
} finally {
setIsStopping(false);
}
}, [session.run_id, session.session_id, onChanged, t]);
return (
<div className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<StatusDot tone={tone} pulse={session.state === "provisioning"} />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{name ?? t("cookieBot.live.unnamedSession")}
</span>
{/* The phase swaps in place: the words change, the row does not move.
The slot is a fixed width so the elapsed clock beside it never
shifts, and the entering label starts at 0.55 rather than 0 if
the animation never runs, the single most important live signal on
the screen is still legible. */}
<span className="w-36 shrink-0 text-right">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={phase}
initial={{ opacity: reduceMotion ? 1 : 0.55 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0.01 : 0.12 }}
className="block truncate text-xs text-muted-foreground"
>
{phase}
</motion.span>
</AnimatePresence>
</span>
<span className="shrink-0 text-xs tabular-nums text-foreground">
{elapsed === null ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-muted-foreground"></span>
</TooltipTrigger>
<TooltipContent>
{t("cookieBot.live.notStartedYet")}
</TooltipContent>
</Tooltip>
) : (
formatElapsed(elapsed)
)}
</span>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 text-xs"
disabled={isStopping}
onClick={() => {
void stop();
}}
>
{t("cookieBot.running.stop")}
</Button>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="tabular-nums">
{countersKnown && total > 0
? t("cookieBot.live.sitesProgress", { visited, total })
: t("cookieBot.live.sitesUnknown")}
</span>
<span className="tabular-nums">
{countersKnown && run
? t("cookieBot.live.consentHandled", {
count: run.consent_dismissed,
})
: t("cookieBot.live.consentUnknown")}
</span>
<span className="tabular-nums">
{session.billed_seconds !== null &&
session.billed_seconds !== undefined
? t("cookieBot.live.billed", {
duration: formatElapsed(session.billed_seconds),
})
: t("cookieBot.live.billedUnknown")}
</span>
{chunks && <span className="tabular-nums">{chunks}</span>}
{closeReason && (
<span className="text-destructive-text">{closeReason}</span>
)}
</div>
{progress !== null && (
<div className="h-1 overflow-hidden rounded-full bg-muted">
<motion.div
initial={false}
animate={{ scaleX: progress }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.22, ease: MOTION_EASE_OUT }
}
style={{ transformOrigin: "left", willChange: "transform" }}
className="h-full w-full bg-success"
/>
</div>
)}
</div>
);
}
+923
View File
@@ -0,0 +1,923 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuChevronRight, LuInfo } from "react-icons/lu";
import {
CADENCES,
type CadenceId,
cadenceForMask,
clockToMinutes,
enableProfileSync,
formatHours,
minutesToClock,
nightsPerWeek,
type PreflightResult,
preflight,
preflightFixLabel,
preflightReason,
profileTimezone,
RemoteHoursMeter,
resolvedOs,
} from "@/components/cookie-bot-shared";
import {
AnimatedTabs,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { AutoHeight } from "@/components/ui/auto-height";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { RippleButton } from "@/components/ui/ripple";
import { StepTransition } from "@/components/ui/step-transition";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import {
type CookieBotConflict,
type CookieBotPlatform,
type CookieBotPreset,
type CookieBotPresetList,
type CookieBotSchedule,
type CookieBotScheduleInput,
checkCookieBotConflicts,
getCookieBotPresets,
saveCookieBotSchedule,
} from "@/lib/cookie-bot";
import { SCHEDULE_BOUNDS } from "@/lib/cookie-bot-limits";
import { canUseCookieBot } from "@/lib/entitlements";
import { MOTION_EASE_OUT } from "@/lib/motion";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile } from "@/types";
/**
* The cap when the server has not published one for the chosen preset. It is a
* user-facing ceiling on machine time, not a description of what the bot does
* with it the contract allows 5..120 and this sits comfortably inside.
*/
const FALLBACK_MAX_MINUTES = 40;
/**
* The server's schedule bounds. Mirrored, never re-declared: see
* `src/lib/cookie-bot-limits.ts` and the test that pins them.
*/
const {
minMaxMinutes: MIN_MAX_MINUTES,
maxMaxMinutes: MAX_MAX_MINUTES,
minSites: MIN_SITES,
maxSites: MAX_SITES,
} = SCHEDULE_BOUNDS;
/** The default start: deep enough into the night to be plausible anywhere. */
const DEFAULT_RUN_AT_MINUTE = 2 * 60;
const PRESET_LABEL_KEYS: Record<string, string> = {
light: "cookieBot.preset.light",
balanced: "cookieBot.preset.balanced",
deep: "cookieBot.preset.deep",
};
interface EnrolTarget {
profile: BrowserProfile;
check: PreflightResult;
}
interface ConflictNotice {
email: string;
time: string;
profileIds: string[];
}
export interface CookieBotEnrolDialogProps {
isOpen: boolean;
onClose: () => void;
/** The profiles being enrolled. One for the fast path, many for a bulk enrol. */
profiles: BrowserProfile[];
/** Pre-fills the form when editing an existing enrolment. */
existing?: CookieBotSchedule | null;
/** Extra work after the shared store has already been refreshed. */
onSaved?: () => void;
/**
* Opens the profile's sync settings, for an end-to-end encrypted profile.
* Omitted where there is no sub-page to hand off to; the reason still shows,
* only the one-click repair is absent.
*/
onOpenProfileSync?: (profile: BrowserProfile) => void;
/** Opens proxy assignment for profiles with no exit node. */
onAssignProxy?: (profileIds: string[]) => void;
}
export function CookieBotEnrolDialog({
isOpen,
onClose,
profiles,
existing,
onSaved,
onOpenProfileSync,
onAssignProxy,
}: CookieBotEnrolDialogProps) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const { user } = useCloudAuth();
// The same entitlement answer every other consumer of the shared store
// passes; see the note in cookie-bot-page.tsx.
const { quota, refresh: refreshCookieBot } = useCookieBot(
canUseCookieBot(user),
cookieBotScopeFor(user),
);
const canReplaceOthers =
!user?.teamId || user.teamRole === "owner" || user.teamRole === "admin";
const [presets, setPresets] = useState<CookieBotPresetList | null>(null);
const [isLoadingPresets, setIsLoadingPresets] = useState(false);
const presetList = useMemo(() => presets?.presets ?? [], [presets]);
const defaultPreset = useMemo(() => pickDefaultPreset(presets), [presets]);
/**
* Read the server's catalogue of intensities.
*
* An imperative loader rather than an effect keyed off an attempt counter,
* because the enrolment cannot name a preset without this: a transient
* failure of a secondary request disables the PRIMARY action, so the retry
* has to be a real call the button can make, not a state flip a lint fix can
* quietly drop from a dependency array.
*/
const loadPresets = useCallback(async () => {
setIsLoadingPresets(true);
try {
setPresets(await getCookieBotPresets());
} catch {
// Losing the catalogue costs the depth control and blocks the save; the
// note beside the retry button says so.
setPresets(null);
} finally {
setIsLoadingPresets(false);
}
}, []);
useEffect(() => {
if (!isOpen) return;
void loadPresets();
}, [isOpen, loadPresets]);
const [preset, setPreset] = useState<string>("");
const [runAt, setRunAt] = useState<string>(
minutesToClock(DEFAULT_RUN_AT_MINUTE),
);
const [daysMask, setDaysMask] = useState<number>(CADENCES[0].mask);
const [maxMinutes, setMaxMinutes] = useState<number>(FALLBACK_MAX_MINUTES);
const [maxMinutesTouched, setMaxMinutesTouched] = useState(false);
const [sitesText, setSitesText] = useState("");
const [adjustOpen, setAdjustOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [fixingId, setFixingId] = useState<string | null>(null);
const [conflict, setConflict] = useState<ConflictNotice | null>(null);
const [conflictAcknowledged, setConflictAcknowledged] = useState(false);
const isEdit = Boolean(existing);
const single = profiles.length === 1 ? profiles[0] : null;
// Reset to the defaults every time the dialog is opened, so a previous
// enrolment's answers never leak into the next one.
useEffect(() => {
if (!isOpen) return;
setPreset(existing?.preset ?? defaultPreset?.id ?? "");
setRunAt(minutesToClock(existing?.run_at_minute ?? DEFAULT_RUN_AT_MINUTE));
setDaysMask(existing?.days_mask ?? CADENCES[0].mask);
setMaxMinutes(
existing?.max_minutes ??
defaultPreset?.typical_minutes ??
FALLBACK_MAX_MINUTES,
);
setMaxMinutesTouched(Boolean(existing));
setSitesText((existing?.sites ?? []).join("\n"));
setAdjustOpen(false);
setConflict(null);
setConflictAcknowledged(false);
setIsSaving(false);
}, [isOpen, existing, defaultPreset]);
// Switching depth moves the cap with it, until the operator sets their own.
useEffect(() => {
if (maxMinutesTouched) return;
const chosen = presetList.find((p) => p.id === preset);
if (chosen?.typical_minutes) setMaxMinutes(chosen.typical_minutes);
}, [preset, presetList, maxMinutesTouched]);
const targets: EnrolTarget[] = useMemo(
() => profiles.map((profile) => ({ profile, check: preflight(profile) })),
[profiles],
);
const eligible = useMemo(
() => targets.filter((target) => target.check.eligible),
[targets],
);
const blocked = useMemo(
() => targets.filter((target) => !target.check.eligible),
[targets],
);
const runAtMinute = clockToMinutes(runAt);
const sites = useMemo(() => normaliseSites(sitesText), [sitesText]);
const sitesTooMany = sites.length > MAX_SITES;
// v1 browses the user's declared sites and nothing else, so an empty list is
// not a schedule the server can accept — it 400s with COOKIE_BOT_SITE_LIMIT
// and, before this, `canSubmit` did not ask. The three-click happy path
// (bot cell -> Enrol -> "Enrol tonight") posted `sites: []` and failed every
// single time, with the only input the bot cannot run without hidden inside a
// collapsed disclosure.
const sitesTooFew = sites.length < MIN_SITES;
const maxMinutesValid =
Number.isFinite(maxMinutes) &&
maxMinutes >= MIN_MAX_MINUTES &&
maxMinutes <= MAX_MAX_MINUTES;
const presetsUnavailable = presets === null;
// A week's machine time from the operator's own two numbers. The budget it is
// compared against is the server's; nothing here decides entitlement.
const weeklyHours = (nightsPerWeek(daysMask) * maxMinutes) / 60;
const remainingHours = quota?.remaining_hours ?? null;
const overBudget =
remainingHours !== null && weeklyHours > remainingHours && !isEdit;
const canSubmit =
eligible.length > 0 &&
preset.length > 0 &&
runAtMinute !== null &&
maxMinutesValid &&
!sitesTooMany &&
!sitesTooFew &&
!isSaving;
// A single-profile enrolment asks the server up front whether a teammate
// already owns this profile's night, so the one decision that matters is
// made before the user commits rather than after.
useEffect(() => {
if (!isOpen || !single || isEdit) return;
let cancelled = false;
void checkCookieBotConflicts(single.id, {})
.then((found) => {
if (cancelled) return;
const overlapping = found.filter((c) => c.enabled);
if (overlapping.length === 0) return;
setConflict(toNotice(overlapping[0], [single.id]));
})
.catch(() => {
// A conflict check that cannot run is not a reason to block enrolment;
// the save path re-detects the same 409 and shows the same block.
});
return () => {
cancelled = true;
};
}, [isOpen, single, isEdit]);
const buildInput = useCallback(
(profile: BrowserProfile): CookieBotScheduleInput | null => {
const minute = clockToMinutes(runAt);
const platform = resolvedOs(profile);
if (minute === null || !platform) return null;
return {
profile_name: profile.name,
platform: platform as CookieBotPlatform,
enabled: true,
run_at_minute: minute,
days_mask: daysMask,
timezone: profileTimezone(profile),
preset,
max_minutes: Math.round(maxMinutes),
sites,
};
},
[runAt, daysMask, preset, maxMinutes, sites],
);
const submit = useCallback(
async (acknowledge: boolean, only?: string[]) => {
const list = only
? eligible.filter((target) => only.includes(target.profile.id))
: eligible;
if (list.length === 0) return;
setIsSaving(true);
let saved = 0;
const conflicted: string[] = [];
let firstError: unknown = null;
let conflictParams: { email: string; time: string } | null = null;
for (const target of list) {
const input = buildInput(target.profile);
if (!input) continue;
try {
await saveCookieBotSchedule(target.profile.id, input, acknowledge);
saved += 1;
} catch (error) {
const parsed = parseBackendError(error);
if (parsed?.code === "COOKIE_BOT_SCHEDULE_CONFLICT") {
conflicted.push(target.profile.id);
if (!conflictParams) {
conflictParams = {
email: parsed.params?.email ?? "",
time: parsed.params?.time ?? minutesToClock(runAtMinute ?? 0),
};
}
continue;
}
if (!firstError) firstError = error;
}
}
setIsSaving(false);
if (conflicted.length > 0 && conflictParams) {
setConflict({
email: conflictParams.email,
time: conflictParams.time,
profileIds: conflicted,
});
if (saved > 0) {
void refreshCookieBot();
onSaved?.();
}
return;
}
if (firstError) {
showErrorToast(translateBackendError(t, firstError));
if (saved > 0) {
void refreshCookieBot();
onSaved?.();
}
return;
}
if (saved > 0) {
showSuccessToast(
isEdit
? t("cookieBot.enrol.saved")
: t("cookieBot.enrol.enrolled", { count: saved }),
);
void refreshCookieBot();
onSaved?.();
onClose();
}
},
[
eligible,
buildInput,
isEdit,
onSaved,
onClose,
t,
runAtMinute,
refreshCookieBot,
],
);
const applyFix = useCallback(
async (target: EnrolTarget) => {
const { profile, check } = target;
if (check.fix === "proxy") {
onAssignProxy?.([profile.id]);
return;
}
if (check.fix === "syncSettings") {
onOpenProfileSync?.(profile);
return;
}
if (check.fix !== "sync") return;
setFixingId(profile.id);
try {
await enableProfileSync(profile.id);
} catch (error) {
showErrorToast(
parseBackendError(error)
? translateBackendError(t, error)
: t("cookieBot.preflight.fixFailed"),
);
} finally {
setFixingId(null);
}
},
[onAssignProxy, onOpenProfileSync, t],
);
const showConflict = conflict !== null && !conflictAcknowledged;
const title = isEdit
? t("cookieBot.enrol.editTitle")
: single
? t("cookieBot.enrol.titleOne", { name: single.name })
: t("cookieBot.enrol.titleCount", { count: profiles.length });
const cadenceId = cadenceForMask(daysMask);
// One complete sentence per cadence rather than a label spliced into a
// fragment: "Runs Nightly at 02:00" only reads correctly in English, and a
// translator needs the whole clause to reorder.
const summaryKey = cadenceId
? `cookieBot.enrol.summary${cadenceId[0].toUpperCase()}${cadenceId.slice(1)}`
: "cookieBot.enrol.summaryCustom";
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="flex max-h-[80vh] max-w-md flex-col">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{t("cookieBot.enrol.description")}
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
{/* The whole default, said once, as one sentence. One key, not five
fragments: a translator has to be free to reorder it. */}
<p className="text-sm tabular-nums text-foreground">
{t(summaryKey, {
count: nightsPerWeek(daysMask),
time: minutesToClock(runAtMinute ?? DEFAULT_RUN_AT_MINUTE),
minutes: Math.round(maxMinutes),
})}
</p>
<div className="flex flex-col gap-1">
<p
className={cn(
"text-xs tabular-nums",
overBudget ? "text-warning-text" : "text-muted-foreground",
)}
>
{remainingHours === null
? t("cookieBot.hours.estimateOnly", {
hours: formatHours(weeklyHours),
})
: overBudget
? t("cookieBot.hours.estimateOverBudget", {
hours: formatHours(weeklyHours),
remaining: formatHours(remainingHours),
})
: t("cookieBot.hours.estimate", {
hours: formatHours(weeklyHours),
remaining: formatHours(remainingHours),
})}
</p>
<RemoteHoursMeter
quota={quota}
isLoading={false}
variant="inline"
/>
</div>
{blocked.length > 0 && (
<div className="flex flex-col gap-2 rounded-md border border-warning/50 bg-warning/10 p-3">
<p className="text-xs font-medium text-warning-text">
{t("cookieBot.preflight.ineligible", {
count: blocked.length,
})}
</p>
{blocked.map((target) => {
const reachable =
target.check.fix === "sync" ||
(target.check.fix === "proxy" && Boolean(onAssignProxy)) ||
(target.check.fix === "syncSettings" &&
Boolean(onOpenProfileSync));
const fixLabel = reachable
? preflightFixLabel(t, target.check.fix)
: null;
return (
<div
key={target.profile.id}
className="flex h-7 items-center gap-2 text-xs"
>
<span className="min-w-0 flex-1 truncate text-foreground">
{target.profile.name}
</span>
<span className="flex shrink-0 items-center gap-1 text-muted-foreground">
{preflightReason(t, target.check)}
{target.check.code === "noExitNode" && <ExitNodeHint />}
</span>
{fixLabel && (
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 text-[11px]"
disabled={fixingId === target.profile.id}
onClick={() => {
void applyFix(target);
}}
>
{fixLabel}
</Button>
)}
</div>
);
})}
</div>
)}
{/* 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
run. It sat inside the collapsed "Adjust schedule" disclosure,
which made the default path a form the server always refused. */}
<Field label={t("cookieBot.enrol.sitesLabel")}>
<Textarea
value={sitesText}
onChange={(event) => {
setSitesText(event.target.value);
}}
rows={4}
placeholder={t("cookieBot.enrol.sitesPlaceholder")}
className="text-xs"
aria-invalid={sitesTooMany || sitesTooFew}
/>
<p
className={cn(
"mt-1 text-[11px]",
sitesTooMany
? "text-destructive-text"
: "text-muted-foreground",
)}
>
{sitesTooMany
? t("cookieBot.enrol.sitesTooMany", { max: MAX_SITES })
: sitesTooFew
? t("cookieBot.enrol.sitesRequired")
: t("cookieBot.enrol.sitesHint", { count: sites.length })}
</p>
</Field>
<div className="rounded-md border border-border">
<button
type="button"
onClick={() => {
setAdjustOpen((open) => !open);
}}
aria-expanded={adjustOpen}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<motion.span
aria-hidden="true"
animate={{ rotate: adjustOpen ? 90 : 0 }}
transition={{
duration: reduceMotion ? 0 : 0.16,
ease: MOTION_EASE_OUT,
}}
className="inline-flex shrink-0"
>
<LuChevronRight className="size-3.5" />
</motion.span>
{t("cookieBot.enrol.adjust")}
</button>
<AutoHeight deps={[adjustOpen, presetList.length]}>
<AnimatePresence initial={false}>
{adjustOpen && (
<motion.div
initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
transition={{
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
}}
className="flex flex-col gap-3 border-t border-border px-3 py-3"
>
<Field label={t("cookieBot.enrol.cadenceLabel")}>
<AnimatedTabs
value={cadenceId ?? "custom"}
onValueChange={(value) => {
const match = CADENCES.find(
(c) => c.id === (value as CadenceId),
);
if (match) setDaysMask(match.mask);
}}
>
<AnimatedTabsList>
{CADENCES.map((cadence) => (
<AnimatedTabsTrigger
key={cadence.id}
value={cadence.id}
className="h-7 px-2.5 text-xs"
>
{t(cadence.labelKey)}
</AnimatedTabsTrigger>
))}
</AnimatedTabsList>
</AnimatedTabs>
</Field>
<div className="flex gap-4">
<Field label={t("cookieBot.enrol.timeLabel")}>
<Input
type="time"
value={runAt}
onChange={(event) => {
setRunAt(event.target.value);
}}
className="h-8 w-28 font-mono tabular-nums"
/>
</Field>
<Field label={t("cookieBot.enrol.maxMinutesLabel")}>
<Input
type="number"
min={MIN_MAX_MINUTES}
max={MAX_MAX_MINUTES}
value={String(maxMinutes)}
onChange={(event) => {
setMaxMinutesTouched(true);
setMaxMinutes(Number(event.target.value));
}}
className="h-8 w-24 tabular-nums"
/>
</Field>
</div>
<p className="text-[11px] text-muted-foreground">
{t("cookieBot.enrol.timeHint")}
</p>
{presetList.length > 0 && (
<Field label={t("cookieBot.enrol.intensityLabel")}>
<AnimatedTabs
value={preset}
onValueChange={(value) => {
setPreset(value);
setMaxMinutesTouched(false);
}}
>
<AnimatedTabsList>
{presetList.map((item) => (
<AnimatedTabsTrigger
key={item.id}
value={item.id}
className="h-7 px-2.5 text-xs"
>
{presetLabel(t, item)}
</AnimatedTabsTrigger>
))}
</AnimatedTabsList>
</AnimatedTabs>
</Field>
)}
</motion.div>
)}
</AnimatePresence>
</AutoHeight>
</div>
{presetsUnavailable && (
<div className="flex items-center gap-2">
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{t("cookieBot.enrol.presetsUnavailable")}
</p>
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 text-[11px]"
disabled={isLoadingPresets}
onClick={() => {
void loadPresets();
}}
>
{t("common.buttons.retry")}
</Button>
</div>
)}
</div>
{/* The footer is one slot: either the actions, or the single decision
a teammate's existing enrolment forces. StepTransition is the shell's
own forward/back language, reused rather than reinvented, and this
genuinely is a step. */}
<StepTransition
transitionKey={showConflict ? "conflict" : "actions"}
direction={showConflict ? 1 : -1}
className="shrink-0"
>
{showConflict && conflict ? (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<p className="text-sm font-medium text-foreground">
{t("cookieBot.conflict.title", {
email: conflict.email,
time: conflict.time,
})}
</p>
<p className="text-xs text-muted-foreground">
{t("cookieBot.conflict.detail")}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
{t("cookieBot.conflict.keepTheirs")}
</Button>
{canReplaceOthers ? (
<RippleButton
size="sm"
disabled={isSaving}
onClick={() => {
setConflictAcknowledged(true);
void submit(true, conflict.profileIds);
}}
>
{t("cookieBot.conflict.replace")}
</RippleButton>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button size="sm" disabled>
{t("cookieBot.conflict.replace")}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t("cookieBot.conflict.replaceForbidden")}
</TooltipContent>
</Tooltip>
)}
{conflict.email.length > 0 && (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
void navigator.clipboard
.writeText(conflict.email)
.then(() => {
showSuccessToast(t("cookieBot.conflict.emailCopied"));
})
.catch(() => {
showErrorToast(t("cookieBot.conflict.copyFailed"));
});
}}
>
{t("cookieBot.conflict.askThem", { email: conflict.email })}
</Button>
)}
</div>
</div>
) : (
<div className="flex items-center justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={onClose}>
{t("common.buttons.cancel")}
</Button>
<RippleButton
size="sm"
autoFocus
disabled={!canSubmit}
onClick={() => {
void submit(conflictAcknowledged);
}}
>
{confirmLabel(t, {
isEdit,
eligible: eligible.length,
total: targets.length,
saving: isSaving,
needsSites: sitesTooFew,
needsPreset: preset.length === 0,
})}
</RippleButton>
</div>
)}
</StepTransition>
</DialogContent>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
{label}
</span>
{children}
</div>
);
}
/**
* 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
* from a hosting ASN costs the profile more than never warming it.
*/
function ExitNodeHint() {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-default text-warning-text">
<LuInfo className="size-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-64">
{t("cookieBot.preflight.exitNodeHint")}
</TooltipContent>
</Tooltip>
);
}
/**
* What the confirm button says, including WHY it is disabled.
*
* A greyed "Enrol tonight" with the explanation twelve pixels away in a
* different colour is a dead end: the user clicks a button that answers
* nothing. Each blocked state names itself instead.
*/
function confirmLabel(
t: ReturnType<typeof useTranslation>["t"],
state: {
isEdit: boolean;
eligible: number;
total: number;
saving: boolean;
needsSites: boolean;
needsPreset: boolean;
},
): string {
if (state.saving) return t("cookieBot.enrol.saving");
if (state.eligible === 0 && !state.isEdit)
return t("cookieBot.enrol.fixFirst");
if (state.needsSites) return t("cookieBot.enrol.addSitesFirst");
if (state.needsPreset) return t("cookieBot.enrol.presetsMissing");
if (state.isEdit) return t("common.buttons.save");
if (state.eligible < state.total) {
return t("cookieBot.enrol.confirmSome", {
eligible: state.eligible,
total: state.total,
});
}
return t("cookieBot.enrol.confirm");
}
function presetLabel(
t: ReturnType<typeof useTranslation>["t"],
preset: CookieBotPreset,
): string {
const key = PRESET_LABEL_KEYS[preset.id];
if (key) return t(key);
// A preset newer than this build still renders: the server ships an English
// label with it, which beats printing a bare id.
return preset.name ?? preset.id;
}
function pickDefaultPreset(
presets: CookieBotPresetList | null,
): CookieBotPreset | null {
if (!presets) return null;
const byId = presets.default_preset
? presets.presets.find((p) => p.id === presets.default_preset)
: undefined;
return (
byId ??
presets.presets.find((p) => p.recommended) ??
presets.presets[0] ??
null
);
}
/**
* The operator's own list, tidied: one entry per line, a bare host promoted to
* https, duplicates dropped. No entry is ever added that the user did not type.
*/
function normaliseSites(text: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const withScheme = /^https?:\/\//i.test(line) ? line : `https://${line}`;
if (seen.has(withScheme)) continue;
seen.add(withScheme);
out.push(withScheme);
}
return out;
}
function toNotice(
conflict: CookieBotConflict,
profileIds: string[],
): ConflictNotice {
return {
email: conflict.email,
time: minutesToClock(conflict.run_at_minute),
profileIds,
};
}
+575
View File
@@ -0,0 +1,575 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { LuCookie, LuPencil, LuTrash2 } from "react-icons/lu";
import {
Area,
AreaChart,
CartesianGrid,
Tooltip as ChartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import type { RunFilter } from "@/components/cookie-bot-activity";
import {
describeCadence,
formatDate,
formatDateTime,
minutesToClock,
parseIso,
StatusDot,
scheduleBlockedReason,
scheduleTone,
sessionDisplayName,
sessionPhaseLabel,
sessionTone,
useNextDue,
} from "@/components/cookie-bot-shared";
import { Button } from "@/components/ui/button";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { RippleButton } from "@/components/ui/ripple";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type {
CookieBotRun,
CookieBotSchedule,
RemoteHoursQuota,
} from "@/lib/cookie-bot";
import type { RemoteSessionState } from "@/lib/remote-sessions";
import { cn } from "@/lib/utils";
import type { BrowserProfile } from "@/types";
const CHART_NIGHTS = 30;
interface CookieBotOverviewProps {
schedules: CookieBotSchedule[];
runs: CookieBotRun[];
live: RemoteSessionState[];
quota: RemoteHoursQuota | null;
profiles: BrowserProfile[];
isLoading: boolean;
currentUserId: string | null;
onEnrol: () => void;
onEditSchedule: (schedule: CookieBotSchedule) => void;
onRemoveSchedule: (schedule: CookieBotSchedule) => void;
onJumpToActivity: (filter: RunFilter) => void;
}
export function CookieBotOverview({
schedules,
runs,
live,
quota,
profiles,
isLoading,
currentUserId,
onEnrol,
onEditSchedule,
onRemoveSchedule,
onJumpToActivity,
}: CookieBotOverviewProps) {
const { t } = useTranslation();
const { next, nextAt, dueCount } = useNextDue(schedules);
const profileIndex = useMemo(
() => new Map(profiles.map((p) => [p.id, p])),
[profiles],
);
const recent = useMemo(() => summariseRecent(runs), [runs]);
const chartData = useMemo(() => nightlyMinutes(runs), [runs]);
const exhausted =
quota !== null && quota.granted_hours > 0 && quota.remaining_hours <= 0;
const resetDate = formatDate(quota?.period_end);
if (!isLoading && schedules.length === 0) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 py-16 text-center">
<LuCookie className="size-12 text-muted-foreground" />
<div>
<p className="text-sm font-medium text-foreground">
{t("cookieBot.empty.title")}
</p>
<p className="mt-1 max-w-md text-xs text-muted-foreground">
{t("cookieBot.empty.hint")}
</p>
</div>
<RippleButton size="sm" onClick={onEnrol}>
{t("cookieBot.empty.cta")}
</RippleButton>
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<TonightStrip
live={live}
nextAt={nextAt}
nextMinute={next?.run_at_minute ?? null}
dueCount={dueCount}
profileIndex={profileIndex}
/>
{exhausted && (
<div className="shrink-0 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning-text">
{resetDate
? t("cookieBot.hours.exhaustedOn", { date: resetDate })
: t("cookieBot.hours.exhausted")}
</div>
)}
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="text-muted-foreground">
{t("cookieBot.lastDay.label")}
</span>
{recent.total === 0 ? (
<span className="text-muted-foreground">
{t("cookieBot.lastDay.none")}
</span>
) : (
<>
<RecentSegment
label={t("cookieBot.lastDay.ran", { count: recent.succeeded })}
tone="text-foreground"
onClick={() => {
onJumpToActivity("succeeded");
}}
/>
{recent.partial > 0 && (
<RecentSegment
label={t("cookieBot.lastDay.partial", {
count: recent.partial,
})}
tone="text-warning-text"
onClick={() => {
onJumpToActivity("partial");
}}
/>
)}
{recent.failed > 0 && (
<RecentSegment
label={t("cookieBot.lastDay.failed", { count: recent.failed })}
tone="text-destructive-text"
onClick={() => {
onJumpToActivity("failed");
}}
/>
)}
</>
)}
</div>
<div className="shrink-0">
<p className="mb-1 text-xs font-medium text-foreground">
{t("cookieBot.chart.machineTime")}
</p>
<div className="h-[clamp(140px,20vh,260px)] w-full">
{isLoading && runs.length === 0 ? (
<Skeleton className="size-full" />
) : (
<ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
<AreaChart
data={chartData}
margin={{ top: 6, right: 8, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="cookieBotMinutesGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="label"
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
minTickGap={24}
/>
<YAxis
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
width={36}
/>
<ChartTooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
const minutes = Number(payload[0]?.value ?? 0);
return (
<div className="rounded-lg border bg-popover px-3 py-2 shadow-lg">
<p className="text-xs font-medium text-popover-foreground">
{String(label)}
</p>
<p className="text-xs tabular-nums text-muted-foreground">
{t("cookieBot.chart.minutes", { minutes })}
</p>
</div>
);
}}
/>
<Area
type="monotone"
dataKey="minutes"
stroke="var(--chart-1)"
fill="url(#cookieBotMinutesGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
<FadingScrollArea
className="min-h-0 flex-1"
style={{ "--scroll-fade-top-offset": "32px" } as React.CSSProperties}
>
<Table
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead className="max-w-0">
{t("cookieBot.enrolled.columnProfile")}
</TableHead>
<TableHead className="hidden w-32 @2xl:table-cell">
{t("cookieBot.enrolled.columnCadence")}
</TableHead>
<TableHead className="w-20">
{t("cookieBot.enrolled.columnTime")}
</TableHead>
<TableHead className="hidden w-40 @3xl:table-cell">
{t("cookieBot.enrolled.columnNextRun")}
</TableHead>
<TableHead className="hidden w-40 @4xl:table-cell">
{t("cookieBot.enrolled.columnLastRun")}
</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && schedules.length === 0
? Array.from({ length: 5 }, (_, i) => (
<TableRow key={`enrolled-skeleton-${i}`}>
<TableCell colSpan={6}>
<div className="flex items-center gap-3">
<Skeleton
className="h-3"
style={{ width: `${30 + ((i * 17) % 40)}%` }}
/>
<div className="flex-1" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
</TableCell>
</TableRow>
))
: schedules.map((schedule) => (
<EnrolledRow
key={`${schedule.owner_user_id ?? "me"}-${schedule.profile_id}`}
schedule={schedule}
mine={
!schedule.owner_user_id ||
schedule.owner_user_id === currentUserId
}
exhausted={exhausted}
onEdit={onEditSchedule}
onRemove={onRemoveSchedule}
/>
))}
</TableBody>
</Table>
</FadingScrollArea>
</div>
);
}
function RecentSegment({
label,
tone,
onClick,
}: {
label: string;
tone: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"cursor-pointer tabular-nums underline-offset-2 transition-colors duration-100 hover:underline",
tone,
)}
>
{label}
</button>
);
}
function TonightStrip({
live,
nextAt,
nextMinute,
dueCount,
profileIndex,
}: {
live: RemoteSessionState[];
nextAt: Date | null;
nextMinute: number | null;
dueCount: number;
profileIndex: Map<string, BrowserProfile>;
}) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const running = live.length > 0;
return (
<div className="flex shrink-0 items-center gap-3 rounded-md border border-border bg-card px-3 py-2.5">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
{running ? t("cookieBot.running.label") : t("cookieBot.tonight.label")}
</span>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={running ? `running-${live.length}` : "idle"}
initial={{ opacity: reduceMotion ? 1 : 0.55 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0.01 : 0.12 }}
className="flex min-w-0 flex-1 items-center gap-2"
>
{running ? (
<>
<StatusDot
tone={sessionTone(live[0])}
pulse={live[0].state === "provisioning"}
/>
<span className="min-w-0 truncate text-sm font-medium text-foreground">
{sessionDisplayName(live[0], profileIndex, undefined) ??
t("cookieBot.live.unnamedSession")}
</span>
<span className="shrink-0 text-sm text-muted-foreground">
{sessionPhaseLabel(t, live[0])}
</span>
{live.length > 1 && (
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{t("cookieBot.running.more", { count: live.length - 1 })}
</span>
)}
</>
) : nextMinute === null ? (
<span className="text-sm text-muted-foreground">
{t("cookieBot.tonight.nothingScheduled")}
</span>
) : (
<span className="text-sm tabular-nums text-foreground">
{t("cookieBot.tonight.nextRun", {
time: minutesToClock(nextMinute),
})}
{nextAt ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-2 cursor-default text-muted-foreground">
{t("cookieBot.tonight.dueCount", { count: dueCount })}
</span>
</TooltipTrigger>
<TooltipContent>
{formatDateTime(nextAt.toISOString()) ?? ""}
</TooltipContent>
</Tooltip>
) : (
<span className="ml-2 text-muted-foreground">
{t("cookieBot.tonight.dueCount", { count: dueCount })}
</span>
)}
</span>
)}
</motion.div>
</AnimatePresence>
</div>
);
}
function EnrolledRow({
schedule,
mine,
exhausted,
onEdit,
onRemove,
}: {
schedule: CookieBotSchedule;
mine: boolean;
exhausted: boolean;
onEdit: (schedule: CookieBotSchedule) => void;
onRemove: (schedule: CookieBotSchedule) => void;
}) {
const { t } = useTranslation();
const blocked = scheduleBlockedReason(t, schedule);
return (
<TableRow className="hover:bg-muted/30">
<TableCell className="max-w-0 truncate">
<span className="flex items-center gap-2">
<StatusDot tone={scheduleTone(schedule)} />
<span className="min-w-0 truncate">{schedule.profile_name}</span>
{!mine && schedule.owner_email && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">
{schedule.owner_email}
</span>
)}
</span>
</TableCell>
<TableCell className="hidden text-muted-foreground @2xl:table-cell">
{describeCadence(t, schedule.days_mask)}
</TableCell>
<TableCell className="tabular-nums">
{minutesToClock(schedule.run_at_minute)}
</TableCell>
{/* The server publishes why tonight would be refused on every read. A
next-run time the enrolment cannot keep is worse than no time. */}
<TableCell className="hidden tabular-nums text-muted-foreground @3xl:table-cell">
{blocked ? (
<span className="text-warning-text">{blocked}</span>
) : exhausted ? (
<span className="text-warning-text">
{t("cookieBot.enrolled.pausedNoHours")}
</span>
) : (
(formatDateTime(schedule.next_run_at) ?? "—")
)}
</TableCell>
<TableCell className="hidden tabular-nums text-muted-foreground @4xl:table-cell">
{formatDateTime(schedule.last_run_at) ??
t("cookieBot.enrolled.neverRun")}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={t("cookieBot.enrolled.edit")}
onClick={() => {
onEdit(schedule);
}}
>
<LuPencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("cookieBot.enrolled.edit")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive-text hover:bg-destructive/10"
aria-label={t("cookieBot.schedule.unenrol")}
onClick={() => {
onRemove(schedule);
}}
>
<LuTrash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("cookieBot.schedule.unenrol")}</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
}
/* -------------------------------------------------------------------------- */
/* Derivations — all of these read run rows the server wrote. Nothing here */
/* predicts, estimates or fills in a value the backend did not report. */
/* -------------------------------------------------------------------------- */
function summariseRecent(runs: CookieBotRun[]) {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
let succeeded = 0;
let partial = 0;
let failed = 0;
let total = 0;
for (const run of runs) {
const at = parseIso(run.started_at ?? run.scheduled_for);
if (!at || at.getTime() < cutoff) continue;
total += 1;
if (run.status === "succeeded") succeeded += 1;
else if (run.status === "partial" || run.status === "skipped") partial += 1;
else if (run.status === "failed") failed += 1;
}
return { succeeded, partial, failed, total };
}
function nightlyMinutes(runs: CookieBotRun[]) {
const buckets = new Map<string, number>();
const days: { key: string; label: string }[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = CHART_NIGHTS - 1; i >= 0; i -= 1) {
const day = new Date(today);
day.setDate(day.getDate() - i);
const key = dayKey(day);
buckets.set(key, 0);
days.push({
key,
label: day.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
}),
});
}
for (const run of runs) {
const at = parseIso(run.started_at ?? run.scheduled_for);
if (!at) continue;
const key = dayKey(at);
if (!buckets.has(key)) continue;
buckets.set(key, (buckets.get(key) ?? 0) + run.billed_seconds / 60);
}
return days.map(({ key, label }) => ({
label,
minutes: Math.round(buckets.get(key) ?? 0),
}));
}
function dayKey(date: Date): string {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
+604
View File
@@ -0,0 +1,604 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCookie, LuSearch } from "react-icons/lu";
import {
CookieBotActivity,
type RunFilter,
} from "@/components/cookie-bot-activity";
import { CookieBotEnrolDialog } from "@/components/cookie-bot-enrol-dialog";
import { CookieBotOverview } from "@/components/cookie-bot-overview";
import { CookieBotScheduleTab } from "@/components/cookie-bot-schedule";
import {
preflight,
preflightReason,
RemoteHoursMeter,
} from "@/components/cookie-bot-shared";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { TeamUsagePanel } from "@/components/team-usage-panel";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Input } from "@/components/ui/input";
import { ProBadge } from "@/components/ui/pro-badge";
import { RippleButton } from "@/components/ui/ripple";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { translateBackendError } from "@/lib/backend-errors";
import {
type CookieBotRun,
type CookieBotSchedule,
deleteCookieBotSchedule,
getCookieBotRuns,
} from "@/lib/cookie-bot";
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, CloudUser } from "@/types";
export type CookieBotTab = "overview" | "schedule" | "activity" | "team";
/** How often the run rows are re-read while something is running. A run's site
* counter advances without a session transition, so the stream alone would show
* a frozen number for an hour. */
const LIVE_POLL_MS = 15_000;
const RUN_PAGE_SIZE = 100;
interface CookieBotPageProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
initialTab?: CookieBotTab;
profiles: BrowserProfile[];
cloudUser: CloudUser | null;
/** Opens the profile's sync settings for an end-to-end encrypted profile. */
onOpenProfileSync: (profile: BrowserProfile) => void;
/** Opens proxy assignment for profiles with no exit node. */
onAssignProxy: (profileIds: string[]) => void;
}
export function CookieBotPage({
isOpen,
onClose,
subPage,
initialTab = "overview",
profiles,
cloudUser,
onOpenProfileSync,
onAssignProxy,
}: CookieBotPageProps) {
const { t } = useTranslation();
const entitlements = getEntitlements(cloudUser);
const unlocked = canUseCookieBot(cloudUser);
const isTeam = entitlements.teamCollaboration && Boolean(cloudUser?.teamId);
const isOwnerOrAdmin =
cloudUser?.teamRole === "owner" || cloudUser?.teamRole === "admin";
const showTeamTab = isTeam && cloudUser?.teamRole === "owner";
const scope = cookieBotScopeFor(cloudUser);
// Enrolments, the pooled budget and the live sessions all come from the one
// shared store the profile table reads, so an edit here moves both at once.
//
// Enabled is `unlocked`, NOT `isOpen && unlocked`: the store is a module
// singleton whose enabled flag is set by whichever consumer's effect ran
// last, so a closed page passing `false` would switch the event stream off
// underneath the rail and the profile table.
const {
schedules: schedulesByProfile,
liveSessions,
quota,
isLoading: isStoreLoading,
error: storeError,
streamConnected,
refresh: refreshStore,
} = useCookieBot(unlocked, scope);
const schedules = useMemo(
() =>
Object.values(schedulesByProfile).sort(
(a, b) =>
a.run_at_minute - b.run_at_minute ||
a.profile_name.localeCompare(b.profile_name),
),
[schedulesByProfile],
);
const live = useMemo(() => Object.values(liveSessions), [liveSessions]);
const liveCount = live.length;
const [activeTab, setActiveTab] = useState<CookieBotTab>(initialTab);
const [runs, setRuns] = useState<CookieBotRun[]>([]);
const [isLoadingRuns, setIsLoadingRuns] = useState(true);
const [runsError, setRunsError] = useState<unknown>(null);
const [runFilter, setRunFilter] = useState<RunFilter>("all");
const hasLoadedRuns = useRef(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [enrolTargets, setEnrolTargets] = useState<BrowserProfile[]>([]);
const [editing, setEditing] = useState<CookieBotSchedule | null>(null);
const [enrolOpen, setEnrolOpen] = useState(false);
const [pendingRemoval, setPendingRemoval] =
useState<CookieBotSchedule | null>(null);
const [isRemoving, setIsRemoving] = useState(false);
const isLoading = isStoreLoading || isLoadingRuns;
const loadError: unknown = runsError ?? storeError;
/**
* Runs are the one thing the shared store does not hold: only this page and
* the per-profile history read them, and they page. `withSpinner` is false
* for every background pass, because swapping a correct table for a skeleton
* every fifteen seconds is worse than a row being a few seconds stale.
*/
const loadRuns = useCallback(
async (withSpinner: boolean) => {
if (withSpinner) setIsLoadingRuns(true);
try {
const page = await getCookieBotRuns({ scope, limit: RUN_PAGE_SIZE });
setRuns(page.runs);
setRunsError(null);
} catch (error) {
// A background refresh that fails leaves the previous rows on screen.
if (withSpinner) setRunsError(error);
} finally {
if (withSpinner) setIsLoadingRuns(false);
}
},
[scope],
);
const reload = useCallback(() => {
void refreshStore();
void loadRuns(true);
}, [refreshStore, loadRuns]);
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
useEffect(() => {
if (!isOpen || !unlocked) {
hasLoadedRuns.current = false;
return;
}
// The first pass shows a skeleton. Every later pass — a session appearing
// or closing, or the poll below — swaps rows in silently. Both matter: the
// live set changing means a run row moved, and a run's site counter
// advances with no session transition at all.
const first = !hasLoadedRuns.current;
hasLoadedRuns.current = true;
void loadRuns(first);
if (liveCount === 0) return;
const id = window.setInterval(() => {
void loadRuns(false);
}, LIVE_POLL_MS);
return () => {
window.clearInterval(id);
};
}, [isOpen, unlocked, liveCount, loadRuns]);
const enrolledIds = useMemo(
() => new Set(Object.keys(schedulesByProfile)),
[schedulesByProfile],
);
const openEnrolFor = useCallback(
(targets: BrowserProfile[], schedule: CookieBotSchedule | null) => {
if (targets.length === 0) return;
setEnrolTargets(targets);
setEditing(schedule);
setEnrolOpen(true);
},
[],
);
const handleEditSchedule = useCallback(
(schedule: CookieBotSchedule) => {
const profile = profiles.find((p) => p.id === schedule.profile_id);
if (!profile) {
showErrorToast(t("cookieBot.enrolled.profileMissing"));
return;
}
openEnrolFor([profile], schedule);
},
[profiles, openEnrolFor, t],
);
const confirmRemoval = useCallback(async () => {
if (!pendingRemoval) return;
setIsRemoving(true);
try {
await deleteCookieBotSchedule(pendingRemoval.profile_id);
showSuccessToast(t("cookieBot.schedule.unenrolled"));
setPendingRemoval(null);
reload();
} catch (error) {
showErrorToast(translateBackendError(t, error));
} finally {
setIsRemoving(false);
}
}, [pendingRemoval, reload, t]);
const dialogBody = !unlocked ? (
<LockedState />
) : (
<div className="@container flex min-h-0 w-full flex-1 flex-col">
<AnimatedTabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value as CookieBotTab);
}}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
<AnimatedTabsList>
<AnimatedTabsTrigger value="overview">
<span>{t("cookieBot.tabs.overview")}</span>
<span className="text-xs tabular-nums">{schedules.length}</span>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="schedule">
{t("cookieBot.tabs.schedule")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="activity">
<span>{t("cookieBot.tabs.activity")}</span>
{live.length > 0 && (
<span className="size-1.5 rounded-full bg-success" />
)}
</AnimatedTabsTrigger>
{showTeamTab && (
<AnimatedTabsTrigger value="team">
{t("cookieBot.tabs.team")}
</AnimatedTabsTrigger>
)}
</AnimatedTabsList>
<div className="flex items-center gap-3">
<RemoteHoursMeter
quota={quota}
isLoading={isStoreLoading && quota === null}
variant="compact"
/>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
className="flex items-center gap-2"
aria-label={t("cookieBot.enrolled.enrolProfiles")}
onClick={() => {
setPickerOpen(true);
}}
>
<GoPlus className="size-4" />
<span className="hidden @2xl:inline">
{t("cookieBot.enrolled.enrolProfiles")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
{t("cookieBot.enrolled.enrolProfiles")}
</TooltipContent>
</Tooltip>
</div>
</div>
{loadError !== null && (
<div className="mt-4 flex shrink-0 items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="min-w-0 flex-1 text-sm text-destructive-text">
{translateBackendError(t, loadError)}
</p>
<RippleButton variant="outline" size="sm" onClick={reload}>
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
<AnimatedTabsContent
value="overview"
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
>
<CookieBotOverview
schedules={schedules}
runs={runs}
live={live}
quota={quota}
profiles={profiles}
isLoading={isLoading}
currentUserId={cloudUser?.id ?? null}
onEnrol={() => {
setPickerOpen(true);
}}
onEditSchedule={handleEditSchedule}
onRemoveSchedule={setPendingRemoval}
onJumpToActivity={(filter) => {
setRunFilter(filter);
setActiveTab("activity");
}}
/>
</AnimatedTabsContent>
<AnimatedTabsContent
value="schedule"
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
>
<CookieBotScheduleTab
schedules={schedules}
isLoading={isStoreLoading}
currentUserId={cloudUser?.id ?? null}
canEditOthers={isOwnerOrAdmin}
onEdit={handleEditSchedule}
onRemove={setPendingRemoval}
/>
</AnimatedTabsContent>
<AnimatedTabsContent
value="activity"
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
>
<CookieBotActivity
live={live}
streamConnected={streamConnected}
runs={runs}
isLoading={isLoadingRuns}
profiles={profiles}
showOperator={isTeam}
filter={runFilter}
onFilterChange={setRunFilter}
onRefresh={reload}
/>
</AnimatedTabsContent>
{showTeamTab && (
<AnimatedTabsContent
value="team"
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
>
{/* One team-usage implementation, shared with the Account page,
so the two never disagree about who spent the pool. */}
<TeamUsagePanel quota={quota} className="min-h-0 flex-1" />
</AnimatedTabsContent>
)}
</AnimatedTabs>
</div>
);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="flex max-h-[85vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
{!subPage && (
<DialogHeader>
<DialogTitle>{t("cookieBot.title")}</DialogTitle>
<DialogDescription>
{t("cookieBot.description")}
</DialogDescription>
</DialogHeader>
)}
{dialogBody}
</DialogContent>
</Dialog>
<EnrolPickerDialog
isOpen={pickerOpen}
profiles={profiles}
enrolledIds={enrolledIds}
onClose={() => {
setPickerOpen(false);
}}
onConfirm={(selected) => {
setPickerOpen(false);
openEnrolFor(selected, null);
}}
/>
<CookieBotEnrolDialog
isOpen={enrolOpen}
onClose={() => {
setEnrolOpen(false);
}}
profiles={enrolTargets}
existing={editing}
onSaved={reload}
onOpenProfileSync={onOpenProfileSync}
onAssignProxy={onAssignProxy}
/>
<DeleteConfirmationDialog
isOpen={pendingRemoval !== null}
onClose={() => {
setPendingRemoval(null);
}}
onConfirm={() => {
void confirmRemoval();
}}
title={t("cookieBot.schedule.unenrolTitle", {
name: pendingRemoval?.profile_name ?? "",
})}
description={t("cookieBot.schedule.unenrolDescription")}
confirmButtonText={t("cookieBot.schedule.unenrol")}
isLoading={isRemoving}
/>
</>
);
}
function LockedState() {
const { t } = useTranslation();
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 py-16 text-center">
<LuCookie className="size-12 text-muted-foreground" />
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">
{t("cookieBot.locked.title")}
</p>
<ProBadge />
</div>
<p className="max-w-md text-xs text-muted-foreground">
{t("cookieBot.locked.hint")}
</p>
</div>
);
}
/**
* Picking which profiles to enrol. Ineligible profiles are shown with their
* reason rather than hidden, so the list matches what the operator sees in the
* main table and the refusal is discovered here, not at 02:00.
*/
function EnrolPickerDialog({
isOpen,
profiles,
enrolledIds,
onClose,
onConfirm,
}: {
isOpen: boolean;
profiles: BrowserProfile[];
enrolledIds: Set<string>;
onClose: () => void;
onConfirm: (selected: BrowserProfile[]) => void;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
if (!isOpen) return;
setSearch("");
setSelected(new Set());
}, [isOpen]);
const rows = useMemo(() => {
const needle = search.trim().toLowerCase();
return profiles
.filter((profile) => profile.name.toLowerCase().includes(needle))
.map((profile) => ({
profile,
check: preflight(profile),
enrolled: enrolledIds.has(profile.id),
}))
.sort((a, b) => {
if (a.check.eligible !== b.check.eligible) {
return a.check.eligible ? -1 : 1;
}
return a.profile.name.localeCompare(b.profile.name);
});
}, [profiles, search, enrolledIds]);
const chosen = useMemo(
() => profiles.filter((profile) => selected.has(profile.id)),
[profiles, selected],
);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="flex max-h-[70vh] max-w-lg flex-col">
<DialogHeader>
<DialogTitle>{t("cookieBot.picker.title")}</DialogTitle>
<DialogDescription>
{t("cookieBot.picker.description")}
</DialogDescription>
</DialogHeader>
<div className="relative shrink-0">
<LuSearch className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
className="h-8 pl-8 text-sm"
placeholder={t("cookieBot.picker.searchPlaceholder")}
/>
</div>
<FadingScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 pr-1">
{rows.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("cookieBot.picker.noProfiles")}
</p>
) : (
rows.map(({ profile, check, enrolled }) => (
<label
key={profile.id}
htmlFor={`cookie-bot-pick-${profile.id}`}
className={cn(
"flex h-8 cursor-pointer items-center gap-2 rounded-md px-2 text-xs transition-colors duration-100 hover:bg-accent hover:text-accent-foreground",
!check.eligible && "cursor-not-allowed opacity-60",
)}
>
<Checkbox
id={`cookie-bot-pick-${profile.id}`}
checked={selected.has(profile.id)}
disabled={!check.eligible}
onCheckedChange={(value) => {
setSelected((prev) => {
const next = new Set(prev);
if (value === true) next.add(profile.id);
else next.delete(profile.id);
return next;
});
}}
/>
<span className="min-w-0 flex-1 truncate">
{profile.name}
</span>
{enrolled && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">
{t("cookieBot.picker.alreadyEnrolled")}
</span>
)}
{!check.eligible && (
<span className="shrink-0 text-muted-foreground">
{preflightReason(t, check)}
</span>
)}
</label>
))
)}
</div>
</FadingScrollArea>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose}>
{t("common.buttons.cancel")}
</Button>
<RippleButton
size="sm"
disabled={chosen.length === 0}
onClick={() => {
onConfirm(chosen);
}}
>
{t("cookieBot.picker.continue", { count: chosen.length })}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+237
View File
@@ -0,0 +1,237 @@
"use client";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
formatDateTime,
formatDuration,
hasRunCounters,
outcomeLabel,
runStatusLabel,
runStatusTone,
StatusDot,
} from "@/components/cookie-bot-shared";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { translateBackendError } from "@/lib/backend-errors";
import {
type CookieBotRun,
cancelCookieBotRun,
getCookieBotRuns,
} from "@/lib/cookie-bot";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
const RUN_PAGE_SIZE = 25;
/** Statuses that are still moving, so the row can offer a stop. */
const IN_FLIGHT = new Set(["pending", "running"]);
function runDurationSeconds(run: CookieBotRun): number | null {
if (run.billed_seconds > 0) return run.billed_seconds;
const started = run.started_at ? new Date(run.started_at).getTime() : NaN;
const ended = run.ended_at ? new Date(run.ended_at).getTime() : NaN;
if (!Number.isNaN(started) && !Number.isNaN(ended) && ended > started) {
return (ended - started) / 1000;
}
return null;
}
interface CookieBotRunsDialogProps {
isOpen: boolean;
onClose: () => void;
profileId: string | null;
profileName?: string;
/** Called after a run is cancelled, so shared state can be re-read. */
onRunCancelled?: () => void;
}
/**
* What the bot actually did for one profile, reached from that profile's row.
*
* The Cookie Bot page owns the fleet-wide activity view; this is the same data
* narrowed to a single profile, which is the question an operator asks while
* looking at the table. Every number is the server's the desktop keeps no run
* history of its own and the status vocabulary is the shared one, so a status
* cannot read differently in two places.
*/
export function CookieBotRunsDialog({
isOpen,
onClose,
profileId,
profileName,
onRunCancelled,
}: CookieBotRunsDialogProps) {
const { t } = useTranslation();
const [runs, setRuns] = React.useState<CookieBotRun[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [cancellingId, setCancellingId] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
if (!profileId) return;
setIsLoading(true);
setError(null);
try {
const page = await getCookieBotRuns({ profileId, limit: RUN_PAGE_SIZE });
setRuns(page.runs);
} catch (err) {
setError(translateBackendError(t as never, err));
} finally {
setIsLoading(false);
}
}, [profileId, t]);
React.useEffect(() => {
if (!isOpen) return;
setRuns([]);
void load();
}, [isOpen, load]);
const handleCancel = React.useCallback(
async (run: CookieBotRun) => {
setCancellingId(run.id);
try {
await cancelCookieBotRun(run.id);
showSuccessToast(t("cookieBot.running.stopped"));
onRunCancelled?.();
await load();
} catch (err) {
showErrorToast(translateBackendError(t as never, err));
} finally {
setCancellingId(null);
}
},
[load, onRunCancelled, t],
);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("cookieBot.history.title")}</DialogTitle>
<DialogDescription>
{profileName ?? t("cookieBot.history.allProfiles")}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
{error ? (
<p className="py-8 text-center text-sm text-destructive-text">
{error}
</p>
) : isLoading && runs.length === 0 ? (
<div className="space-y-2 py-2">
{Array.from({ length: 5 }, (_, index) => (
<Skeleton
key={`run-skeleton-${index}`}
className="h-7 w-full"
/>
))}
</div>
) : runs.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("cookieBot.history.empty")}
</p>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead>{t("cookieBot.history.columnStarted")}</TableHead>
<TableHead>{t("cookieBot.history.columnDuration")}</TableHead>
<TableHead>{t("cookieBot.history.columnSites")}</TableHead>
<TableHead>{t("cookieBot.history.columnStatus")}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => {
const duration = runDurationSeconds(run);
const started =
formatDateTime(run.started_at ?? run.scheduled_for) ?? "—";
return (
<TableRow key={run.id}>
<TableCell className="text-xs tabular-nums whitespace-nowrap">
{started}
</TableCell>
<TableCell className="text-xs tabular-nums">
{duration === null ? "—" : formatDuration(t, duration)}
</TableCell>
{/* Never a confident `0/12`: nothing writes these
counters yet, so the column default is not a fact
about what the bot did. */}
<TableCell className="text-xs tabular-nums">
{hasRunCounters(run)
? t("cookieBot.history.sitesVisited", {
visited: run.sites_visited,
total: run.sites_total,
})
: t("cookieBot.history.sitesUnknown")}
{run.sites_failed > 0 && (
<span className="ml-1 text-warning-text">
{t("cookieBot.history.sitesFailed", {
count: run.sites_failed,
})}
</span>
)}
</TableCell>
<TableCell className="text-xs">
<span className="flex items-center gap-1.5">
<StatusDot
tone={runStatusTone(run.status)}
pulse={run.status === "running"}
className="size-1.5"
/>
{runStatusLabel(t, run.status)}
</span>
{run.outcome_code && (
<span className="mt-0.5 block text-[11px] text-muted-foreground">
{outcomeLabel(t, run.outcome_code)}
</span>
)}
</TableCell>
<TableCell className="text-right">
{IN_FLIGHT.has(run.status) && (
<LoadingButton
size="sm"
variant="outline"
isLoading={cancellingId === run.id}
onClick={() => {
void handleCancel(run);
}}
className="h-6 text-[11px]"
>
{t("cookieBot.running.stop")}
</LoadingButton>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
}
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import {
describeCadence,
minutesToClock,
StatusDot,
scheduleBlockedReason,
scheduleTone,
} from "@/components/cookie-bot-shared";
import { Button } from "@/components/ui/button";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { CookieBotSchedule } from "@/lib/cookie-bot";
import { cn } from "@/lib/utils";
/** A slot holding this many enrolments is worth flagging: the fleet leases a
* handful of machines per platform, so a pile-up at one minute is a real
* capacity fact, not a decoration. */
const CROWDED_SLOT = 4;
interface Slot {
hour: number;
entries: CookieBotSchedule[];
}
interface CookieBotScheduleTabProps {
schedules: CookieBotSchedule[];
isLoading: boolean;
currentUserId: string | null;
canEditOthers: boolean;
onEdit: (schedule: CookieBotSchedule) => void;
onRemove: (schedule: CookieBotSchedule) => void;
}
/**
* The night, drawn as a night. Every enrolment the caller can see sits under
* the hour it starts, so two operators aiming at the same profile or twelve
* profiles aiming at 02:00 is visible before it becomes a 409 at 02:00.
*/
export function CookieBotScheduleTab({
schedules,
isLoading,
currentUserId,
canEditOthers,
onEdit,
onRemove,
}: CookieBotScheduleTabProps) {
const { t } = useTranslation();
const rows = useMemo(() => buildRows(schedules), [schedules]);
if (isLoading && schedules.length === 0) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 pt-2">
{Array.from({ length: 5 }, (_, i) => (
<div key={`slot-skeleton-${i}`} className="flex items-center gap-3">
<Skeleton className="h-3 w-10" />
<Skeleton
className="h-3"
style={{ width: `${30 + ((i * 17) % 40)}%` }}
/>
</div>
))}
</div>
);
}
if (schedules.length === 0) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center py-16">
<p className="text-sm text-muted-foreground">
{t("cookieBot.schedule.empty")}
</p>
</div>
);
}
return (
<FadingScrollArea
className="min-h-0 flex-1"
style={{ "--scroll-fade-top-offset": "16px" } as React.CSSProperties}
>
<div className="flex flex-col pr-1">
{rows.map((row) =>
row.kind === "gap" ? (
<div
key={`gap-${row.from}`}
className="flex h-6 items-center gap-2 pl-14 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<span>
{t("cookieBot.schedule.quietHours", { count: row.count })}
</span>
<span className="h-px flex-1 rounded-full bg-border" />
</div>
) : (
<div key={`slot-${row.slot.hour}`} className="flex gap-3 pb-4">
<span className="w-14 shrink-0 pt-1 text-right text-xs tabular-nums text-muted-foreground">
{minutesToClock(row.slot.hour * 60)}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 border-l border-border pl-3">
{row.slot.entries.map((schedule) => {
const mine =
!schedule.owner_user_id ||
schedule.owner_user_id === currentUserId;
const editable = mine || canEditOthers;
const blocked = scheduleBlockedReason(t, schedule);
return (
<div
key={`${schedule.owner_user_id ?? "me"}-${schedule.profile_id}`}
className="group flex h-7 items-center gap-2 rounded-md px-2 text-xs transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<StatusDot
tone={scheduleTone(schedule)}
className="size-1.5"
/>
<span className="min-w-0 flex-1 truncate">
{schedule.profile_name}
</span>
{/* "This cannot run" beats "it runs nightly": an
enrolment the server will refuse should not read as a
cadence it is about to keep. */}
<span
className={cn(
"shrink-0 text-[10px] uppercase tracking-wide",
blocked
? "text-warning-text"
: "text-muted-foreground",
)}
>
{blocked ?? describeCadence(t, schedule.days_mask)}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{minutesToClock(schedule.run_at_minute)}
</span>
{!mine && schedule.owner_email && (
<span className="hidden max-w-40 shrink-0 truncate text-[10px] uppercase tracking-wide text-muted-foreground @2xl:inline">
{schedule.owner_email}
</span>
)}
<SlotAction
label={t("cookieBot.enrolled.edit")}
forbidden={!editable}
onClick={() => {
onEdit(schedule);
}}
>
<LuPencil className="size-3.5" />
</SlotAction>
<SlotAction
label={t("cookieBot.schedule.unenrol")}
forbidden={!editable}
destructive
onClick={() => {
onRemove(schedule);
}}
>
<LuTrash2 className="size-3.5" />
</SlotAction>
</div>
);
})}
{row.slot.entries.length >= CROWDED_SLOT && (
<span className="px-2 pt-1 text-[11px] text-warning-text">
{t("cookieBot.schedule.crowded", {
count: row.slot.entries.length,
})}
</span>
)}
</div>
</div>
),
)}
</div>
</FadingScrollArea>
);
}
function SlotAction({
label,
forbidden,
destructive,
onClick,
children,
}: {
label: string;
forbidden: boolean;
destructive?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<Button
variant="ghost"
size="icon"
className={cn(
"size-7",
destructive && "text-destructive-text hover:bg-destructive/10",
)}
aria-label={label}
disabled={forbidden}
onClick={onClick}
>
{children}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{forbidden ? t("cookieBot.conflict.replaceForbidden") : label}
</TooltipContent>
</Tooltip>
);
}
type Row =
| { kind: "slot"; slot: Slot }
| { kind: "gap"; from: number; count: number };
/**
* Groups enrolments into the hour they start and collapses the empty stretches
* between them. A 24-row skeleton of empty hours would be a grid pretending to
* be information.
*/
function buildRows(schedules: CookieBotSchedule[]): Row[] {
const byHour = new Map<number, CookieBotSchedule[]>();
for (const schedule of schedules) {
const hour = Math.floor(schedule.run_at_minute / 60) % 24;
const bucket = byHour.get(hour);
if (bucket) bucket.push(schedule);
else byHour.set(hour, [schedule]);
}
const hours = [...byHour.keys()].sort((a, b) => a - b);
const rows: Row[] = [];
let previous: number | null = null;
for (const hour of hours) {
if (previous !== null && hour - previous > 1) {
rows.push({
kind: "gap",
from: previous + 1,
count: hour - previous - 1,
});
}
const entries = (byHour.get(hour) ?? []).sort(
(a, b) =>
a.run_at_minute - b.run_at_minute ||
a.profile_name.localeCompare(b.profile_name),
);
rows.push({ kind: "slot", slot: { hour, entries } });
previous = hour;
}
return rows;
}
+821
View File
@@ -0,0 +1,821 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import type { TFunction } from "i18next";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type {
CookieBotRun,
CookieBotSchedule,
RemoteHoursQuota,
} from "@/lib/cookie-bot";
import { MOTION_EASE_OUT } from "@/lib/motion";
import type { RemoteSessionState } from "@/lib/remote-sessions";
import { cn } from "@/lib/utils";
import type { BrowserProfile, WayfernFingerprintConfig } from "@/types";
/* -------------------------------------------------------------------------- */
/* Cadence */
/* -------------------------------------------------------------------------- */
/**
* Weekday bitmask, bit 0 = Monday. The masks below are the only three the
* enrolment dialog offers; anything else that comes back from the server is
* rendered as its own weekday list rather than forced into one of these.
*/
export const DAYS_NIGHTLY = 127;
export const DAYS_WEEKNIGHTS = 31;
export const DAYS_ALTERNATE = 85; // Mon / Wed / Fri / Sun
export type CadenceId = "nightly" | "weeknights" | "alternate";
export const CADENCES: { id: CadenceId; mask: number; labelKey: string }[] = [
{
id: "nightly",
mask: DAYS_NIGHTLY,
labelKey: "cookieBot.enrol.cadenceNightly",
},
{
id: "weeknights",
mask: DAYS_WEEKNIGHTS,
labelKey: "cookieBot.enrol.cadenceWeeknights",
},
{
id: "alternate",
mask: DAYS_ALTERNATE,
labelKey: "cookieBot.enrol.cadenceAlternate",
},
];
export function cadenceForMask(mask: number): CadenceId | null {
return CADENCES.find((c) => c.mask === mask)?.id ?? null;
}
export function nightsPerWeek(mask: number): number {
let count = 0;
for (let bit = 0; bit < 7; bit += 1) {
if ((mask & (1 << bit)) !== 0) count += 1;
}
return count;
}
/** A human cadence label. Unknown masks fall back to the night count. */
export function describeCadence(t: TFunction, mask: number): string {
const id = cadenceForMask(mask);
if (id) {
return t(CADENCES.find((c) => c.id === id)?.labelKey ?? "");
}
return t("cookieBot.enrol.cadenceCustom", { count: nightsPerWeek(mask) });
}
/* -------------------------------------------------------------------------- */
/* Time formatting */
/* -------------------------------------------------------------------------- */
/** `137` -> `02:17`. Always zero-padded so the column stays on one grid. */
export function minutesToClock(minutes: number): string {
const safe = ((Math.round(minutes) % 1440) + 1440) % 1440;
const h = Math.floor(safe / 60);
const m = safe % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** `02:17` -> `137`. Returns null for anything that isn't a real time. */
export function clockToMinutes(value: string): number | null {
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
if (!match) return null;
const h = Number(match[1]);
const m = Number(match[2]);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
return h * 60 + m;
}
/** `724` -> `12:04`. Used for the live elapsed clock; never rounds up. */
export function formatElapsed(seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) {
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
/** `724` -> `12m 04s`, for a finished run's duration column. */
export function formatDuration(t: TFunction, seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return t("cookieBot.duration.hm", { hours: h, minutes: m });
if (m > 0) {
return t("cookieBot.duration.ms", {
minutes: m,
seconds: String(s).padStart(2, "0"),
});
}
return t("cookieBot.duration.s", { seconds: s });
}
/** Parses a server ISO timestamp. Returns null rather than an Invalid Date. */
export function parseIso(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export function formatDateTime(
value: string | null | undefined,
): string | null {
const date = parseIso(value);
if (!date) return null;
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
export function formatDate(value: string | null | undefined): string | null {
const date = parseIso(value);
if (!date) return null;
return date.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
});
}
/* -------------------------------------------------------------------------- */
/* Preflight */
/* -------------------------------------------------------------------------- */
/**
* Hosts the fleet can lease. Mirrors `BOT_PLATFORMS` in
* `src-tauri/src/cookie_bot.rs`; a profile built for anything else has no
* machine to run on and is refused before a schedule row is ever written.
*/
const BOT_PLATFORMS = ["windows", "macos"];
export type PreflightCode =
| "syncOff"
| "encrypted"
| "unknownPlatform"
| "unsupportedPlatform"
| "noExitNode";
/** The one-click repairs a failed preflight can name. */
export type PreflightFix = "sync" | "syncSettings" | "proxy";
export interface PreflightResult {
eligible: boolean;
code: PreflightCode | null;
/** Substituted into the reason line, e.g. the refused OS name. */
params: Record<string, string>;
/** Which one-click repair applies, when one does. */
fix: PreflightFix | null;
}
const ELIGIBLE: PreflightResult = {
eligible: true,
code: null,
params: {},
fix: null,
};
/** The OS a profile claims, from its own record then its fingerprint. */
export function resolvedOs(profile: BrowserProfile): string | null {
return profile.host_os ?? profile.wayfern_config?.os ?? null;
}
/**
* The exact refusals `cookie_bot::bot_precondition` applies, evaluated here so
* a user finds out at enrolment rather than at 02:00. Keeping the two in step
* matters: a profile this says is fine but the backend refuses would burn a
* schedule row and a night.
*/
export function preflight(profile: BrowserProfile): PreflightResult {
const syncMode = profile.sync_mode ?? "Disabled";
if (syncMode === "Disabled") {
return { eligible: false, code: "syncOff", params: {}, fix: "sync" };
}
if (syncMode === "Encrypted") {
return {
eligible: false,
code: "encrypted",
params: {},
fix: "syncSettings",
};
}
const os = resolvedOs(profile);
if (!os) {
return {
eligible: false,
code: "unknownPlatform",
params: {},
fix: null,
};
}
if (!BOT_PLATFORMS.includes(os)) {
return {
eligible: false,
code: "unsupportedPlatform",
params: { os },
fix: null,
};
}
if (!profile.proxy_id && !profile.vpn_id) {
return { eligible: false, code: "noExitNode", params: {}, fix: "proxy" };
}
return ELIGIBLE;
}
export function preflightReason(t: TFunction, result: PreflightResult): string {
switch (result.code) {
case "syncOff":
return t("cookieBot.preflight.reasonSync");
case "encrypted":
return t("cookieBot.preflight.reasonEncrypted");
case "unknownPlatform":
return t("cookieBot.preflight.reasonNoFingerprint");
case "unsupportedPlatform":
return t("cookieBot.preflight.reasonCrossOs", {
os: result.params.os ?? "",
});
case "noExitNode":
return t("cookieBot.preflight.reasonNoExitNode");
default:
return "";
}
}
export function preflightFixLabel(
t: TFunction,
fix: PreflightResult["fix"],
): string | null {
switch (fix) {
case "sync":
return t("cookieBot.preflight.fixSync");
case "syncSettings":
return t("cookieBot.preflight.fixEncrypted");
case "proxy":
return t("cookieBot.preflight.fixProxy");
default:
return null;
}
}
/** Turning sync on is the one repair the dialog can perform by itself. */
export function enableProfileSync(profileId: string): Promise<void> {
return invoke<void>("set_profile_sync_mode", {
profileId,
syncMode: "Regular",
});
}
/* -------------------------------------------------------------------------- */
/* Timezone */
/* -------------------------------------------------------------------------- */
/**
* The timezone the profile pretends to live in. The run is anchored to it so
* a "02:00" enrolment means 02:00 where the identity claims to be, not where
* the operator happens to be sitting. Falls back to this machine's zone.
*/
export function profileTimezone(profile: BrowserProfile): string {
const raw = profile.wayfern_config?.fingerprint;
if (raw) {
try {
const parsed = JSON.parse(raw) as WayfernFingerprintConfig;
if (typeof parsed.timezone === "string" && parsed.timezone.length > 0) {
return parsed.timezone;
}
} catch {
// A fingerprint we cannot parse is not an error here — the local zone is
// a correct, if less specific, anchor.
}
}
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}
/* -------------------------------------------------------------------------- */
/* Run + session status */
/* -------------------------------------------------------------------------- */
export type StatusTone =
| "success"
| "warning"
| "destructive"
| "muted"
| "live";
export function runStatusTone(status: string): StatusTone {
switch (status) {
case "succeeded":
return "success";
case "running":
case "pending":
return "live";
case "partial":
case "skipped":
return "warning";
case "failed":
return "destructive";
default:
return "muted";
}
}
export function runStatusLabel(t: TFunction, status: string): string {
switch (status) {
case "pending":
return t("cookieBot.runStatus.pending");
case "running":
return t("cookieBot.runStatus.running");
case "succeeded":
return t("cookieBot.runStatus.succeeded");
case "partial":
return t("cookieBot.runStatus.partial");
case "failed":
return t("cookieBot.runStatus.failed");
case "skipped":
return t("cookieBot.runStatus.skipped");
case "cancelled":
return t("cookieBot.runStatus.cancelled");
default:
// The status vocabulary belongs to the server. One it adds after this
// build renders as itself rather than as a blank cell.
return status;
}
}
/**
* Every value of the server's `CookieBotOutcomeCode`, mapped to a translated
* sentence.
*
* The code exists so a refusal is something a user can SEE in their history.
* Printed raw it was a snake_case English token in ten locales: a Russian
* operator asking why last night did nothing read "Причина: no_capacity".
* A value newer than this build still falls through to its own name, which
* beats a blank cell, but every code the server defines today has a sentence.
*/
const OUTCOME_KEYS: Record<string, string> = {
not_entitled: "cookieBot.outcome.notEntitled",
sync_disabled: "cookieBot.outcome.syncDisabled",
encrypted_sync: "cookieBot.outcome.encryptedSync",
proxy_required: "cookieBot.outcome.proxyRequired",
touch_fingerprint: "cookieBot.outcome.touchFingerprint",
platform_unsupported: "cookieBot.outcome.platformUnsupported",
no_sites: "cookieBot.outcome.noSites",
quota_exhausted: "cookieBot.outcome.quotaExhausted",
profile_locked: "cookieBot.outcome.profileLocked",
no_capacity: "cookieBot.outcome.noCapacity",
manager_error: "cookieBot.outcome.managerError",
budget_exceeded: "cookieBot.outcome.budgetExceeded",
cancelled_by_user: "cookieBot.outcome.cancelledByUser",
};
export function outcomeLabel(
t: TFunction,
code: string | null | undefined,
): string | null {
if (!code) return null;
const key = OUTCOME_KEYS[code];
return key ? t(key) : t("cookieBot.outcome.unknown", { code });
}
/**
* The session state machine, named honestly. `provisioning -> ready -> live ->
* closed`, with `error` reachable from any of the first three, is what the
* backend actually reports; nothing here infers a phase the backend has not
* sent.
*/
export function sessionPhaseLabel(
t: TFunction,
session: RemoteSessionState,
): string {
switch (session.state) {
case "provisioning":
return t("cookieBot.status.provisioning");
case "ready":
return t("cookieBot.status.ready");
case "live":
return t("cookieBot.status.warming");
case "closed":
return t("cookieBot.status.finished");
case "error":
return t("cookieBot.status.failed");
default:
return session.state;
}
}
export function sessionTone(session: RemoteSessionState): StatusTone {
switch (session.state) {
case "provisioning":
return "warning";
case "ready":
return "live";
case "live":
return "success";
case "closed":
return "muted";
// A session the fleet failed is not a session that quietly finished. It
// read as an untranslated `error` beside the same grey dot as an idle one,
// in the exact place a user checks whether last night worked.
case "error":
return "destructive";
default:
return "muted";
}
}
/**
* Why a session ended, when the backend named a reason.
*
* `close_reason` has been on the wire since the stream existed and nothing read
* it, so a session that hit the two-hour cap and one the user stopped looked
* identical.
*/
export function sessionCloseReason(
t: TFunction,
session: RemoteSessionState,
): string | null {
switch (session.close_reason) {
case null:
case undefined:
case "":
return null;
case "stopped_by_user":
return t("cookieBot.closeReason.stoppedByUser");
case "max_duration":
return t("cookieBot.closeReason.maxDuration");
default:
// The vocabulary is the fleet's and it grows. An unknown reason still
// beats no reason, but it is labelled so it does not read as a sentence.
return t("cookieBot.closeReason.other", { reason: session.close_reason });
}
}
const TONE_DOT: Record<StatusTone, string> = {
success: "bg-success",
warning: "bg-warning",
destructive: "bg-destructive",
muted: "bg-muted-foreground",
live: "bg-warning",
};
/**
* The app's one status vocabulary: a bare dot, no chip and no background.
* `pulse` is reserved for "a transfer is in progress", exactly as the sync dots
* use it, so a pulsing dot always means the same thing.
*/
export function StatusDot({
tone,
pulse,
className,
}: {
tone: StatusTone;
pulse?: boolean;
className?: string;
}) {
return (
<span
aria-hidden="true"
className={cn(
"inline-block size-2 shrink-0 rounded-full",
TONE_DOT[tone],
pulse && "animate-pulse",
className,
)}
/>
);
}
/* -------------------------------------------------------------------------- */
/* Numbers */
/* -------------------------------------------------------------------------- */
/**
* A cookie delta. A gain reads as a gain, a loss reads with a real minus sign
* (U+2212, not a hyphen), and "nothing happened" reads as an em dash instead of
* a confident zero.
*/
export function CookieDelta({
value,
className,
}: {
value: number | null | undefined;
className?: string;
}) {
if (value === null || value === undefined) {
return (
<span className={cn("text-muted-foreground", className)} aria-hidden>
</span>
);
}
if (value === 0) {
return (
<span className={cn("text-muted-foreground", className)} aria-hidden>
</span>
);
}
const positive = value > 0;
return (
<span
className={cn(
"tabular-nums",
positive ? "text-chart-1" : "text-muted-foreground",
className,
)}
>
{positive ? `+${value}` : `${Math.abs(value)}`}
</span>
);
}
/** One decimal, but only when it earns one: `4.7 h`, `128 h`. */
export function formatHours(hours: number): string {
if (!Number.isFinite(hours)) return "0";
if (hours >= 100 || Number.isInteger(hours)) return String(Math.round(hours));
return hours.toFixed(1);
}
/* -------------------------------------------------------------------------- */
/* Remote hours */
/* -------------------------------------------------------------------------- */
function meterFill(usedRatio: number): string {
if (usedRatio >= 1) return "bg-destructive";
if (usedRatio >= 0.9) return "bg-warning";
return "bg-foreground";
}
/**
* The hours meter. The track renders at full opacity on the first paint with
* the label already in place; only the numerals wait for the server. The fill
* is a scaleX transform with `initial={false}` so the first frame is the true
* value and never grows in and the radius lives on the track, so the fill's
* caps cannot flip shape halfway through a change.
*/
export function RemoteHoursMeter({
quota,
isLoading,
variant = "compact",
className,
}: {
quota: RemoteHoursQuota | null;
isLoading: boolean;
variant?: "compact" | "full" | "inline";
className?: string;
}) {
const reduceMotion = useReducedMotion();
const granted = quota?.granted_hours ?? 0;
const used = quota?.used_hours ?? 0;
const remaining = quota?.remaining_hours ?? 0;
const ratio = granted > 0 ? Math.min(1, Math.max(0, used / granted)) : 0;
const resets = formatDate(quota?.period_end);
const bar = (
<div
className={cn(
"h-1 overflow-hidden rounded-full bg-muted",
variant === "compact" ? "w-32" : "w-full",
)}
>
<motion.div
initial={false}
animate={{ scaleX: ratio }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.22, ease: MOTION_EASE_OUT }
}
style={{ transformOrigin: "left", willChange: "transform" }}
className={cn("h-full w-full", meterFill(ratio))}
/>
</div>
);
if (variant === "inline") {
return <div className={cn("w-full", className)}>{bar}</div>;
}
return (
<div className={cn("flex flex-col items-end gap-1", className)}>
<div className="flex items-baseline gap-1.5">
{isLoading ? (
<Skeleton className="h-3 w-24" />
) : (
<RemoteHoursReadout
remaining={remaining}
granted={granted}
resets={resets}
/>
)}
</div>
{bar}
</div>
);
}
function RemoteHoursReadout({
remaining,
granted,
resets,
}: {
remaining: number;
granted: number;
resets: string | null;
}) {
const { t } = useTranslation();
const text = t("cookieBot.hours.remaining", {
remaining: formatHours(remaining),
total: formatHours(granted),
});
if (!resets) {
return (
<span className="text-xs tabular-nums text-muted-foreground">{text}</span>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-xs tabular-nums text-muted-foreground">
{text}
</span>
</TooltipTrigger>
<TooltipContent>
{t("cookieBot.hours.resets", { date: resets })}
</TooltipContent>
</Tooltip>
);
}
/* -------------------------------------------------------------------------- */
/* Live sessions */
/* -------------------------------------------------------------------------- */
/**
* A ticking wall clock, only while something is actually running. Returns
* `Date.now()` once a second; consumers render it into `tabular-nums` so a
* changing digit never reflows the row.
*/
export function useSecondTicker(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
setNow(Date.now());
const id = window.setInterval(() => {
setNow(Date.now());
}, 1000);
return () => {
window.clearInterval(id);
};
}, [active]);
return now;
}
/* -------------------------------------------------------------------------- */
/* Joins */
/* -------------------------------------------------------------------------- */
export function indexProfiles(
profiles: BrowserProfile[],
): Map<string, BrowserProfile> {
return new Map(profiles.map((p) => [p.id, p]));
}
export function indexRunsBySession(
runs: CookieBotRun[],
): Map<string, CookieBotRun> {
const map = new Map<string, CookieBotRun>();
for (const run of runs) {
if (run.session_id) map.set(run.session_id, run);
}
return map;
}
export function indexRunsById(runs: CookieBotRun[]): Map<string, CookieBotRun> {
return new Map(runs.map((run) => [run.id, run]));
}
/**
* The name to print for a session. The local profile record wins because it is
* what the operator renamed; the run row is the fallback for a teammate's
* profile this machine has never held.
*/
export function sessionDisplayName(
session: RemoteSessionState,
profiles: Map<string, BrowserProfile>,
run: CookieBotRun | undefined,
): string | null {
if (session.profile_id) {
const profile = profiles.get(session.profile_id);
if (profile) return profile.name;
}
return run?.profile_name ?? null;
}
export function scheduleSortKey(schedule: CookieBotSchedule): number {
return schedule.run_at_minute;
}
/**
* The dot a stored enrolment gets.
*
* `blocked_by` outranks `enabled`: an armed schedule the server will refuse is
* not a healthy one, and showing it green with a next-run time is how a
* detached proxy stayed invisible until the run was skipped at 02:00.
*/
export function scheduleTone(schedule: CookieBotSchedule): StatusTone {
if (!schedule.enabled) return "muted";
return schedule.blocked_by ? "warning" : "success";
}
/** Why this enrolment cannot run tonight, translated, or null. */
export function scheduleBlockedReason(
t: TFunction,
schedule: CookieBotSchedule,
): string | null {
if (!schedule.enabled) return null;
return outcomeLabel(t, schedule.blocked_by);
}
/** Seconds a session has been alive, or null when the backend has not said. */
export function sessionElapsedSeconds(
session: RemoteSessionState,
now: number,
): number | null {
const started = parseIso(session.started_at);
if (!started) return null;
const end = parseIso(session.ended_at)?.getTime() ?? now;
return Math.max(0, Math.floor((end - started.getTime()) / 1000));
}
/**
* Whether a run's per-site counters mean anything yet.
*
* `sites_visited`, `sites_failed` and `consent_dismissed` are columns the
* server declares with `DEFAULT 0` and, today, nothing ever writes: the fleet
* computes them but donutbrowser-infra does not ingest them. Rendering the
* default as a fact told a paying user their run visited "0 of 12 sites" and
* drew a success-green progress bar pinned at zero for the whole night.
*
* So a run is only credited with counters once one of them is non-zero. Until
* then the UI says it does not know, which is the truth. The moment the
* ingestion lands this starts reporting real numbers with no further change.
*/
export function hasRunCounters(run: {
sites_visited: number;
sites_failed: number;
consent_dismissed: number;
}): boolean {
return (
run.sites_visited > 0 || run.sites_failed > 0 || run.consent_dismissed > 0
);
}
/**
* Runs whose schedule fires on the next occurrence of their local start time.
* Purely a read of the server's own `next_run_at` nothing here computes a
* schedule, it only sorts what the server already decided.
*/
export function sortByNextRun(
schedules: CookieBotSchedule[],
): CookieBotSchedule[] {
return [...schedules].sort((a, b) => {
const at = parseIso(a.next_run_at)?.getTime();
const bt = parseIso(b.next_run_at)?.getTime();
if (at !== undefined && bt !== undefined) return at - bt;
if (at !== undefined) return -1;
if (bt !== undefined) return 1;
return scheduleSortKey(a) - scheduleSortKey(b);
});
}
export function useNextDue(schedules: CookieBotSchedule[]) {
return useMemo(() => {
const enabled = schedules.filter((s) => s.enabled);
const sorted = sortByNextRun(enabled);
const first = sorted[0] ?? null;
const firstAt = parseIso(first?.next_run_at ?? null);
const dueCount = firstAt
? sorted.filter((s) => {
const at = parseIso(s.next_run_at);
if (!at) return false;
return at.getTime() - firstAt.getTime() < 12 * 60 * 60 * 1000;
}).length
: enabled.length;
return { next: first, nextAt: firstAt, dueCount };
}, [schedules]);
}
+586 -8
View File
@@ -27,6 +27,7 @@ import {
LuCookie,
LuInfo,
LuLock,
LuMoon,
LuPlay,
LuPuzzle,
LuSquare,
@@ -35,6 +36,22 @@ import {
LuUserSearch,
LuUsers,
} from "react-icons/lu";
import { CookieBotEnrolDialog } from "@/components/cookie-bot-enrol-dialog";
import { CookieBotRunsDialog } from "@/components/cookie-bot-runs-dialog";
import {
describeCadence,
enableProfileSync,
minutesToClock,
outcomeLabel,
type PreflightFix,
preflight,
preflightFixLabel,
preflightReason,
runStatusLabel,
StatusDot,
sessionPhaseLabel,
sessionTone,
} from "@/components/cookie-bot-shared";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import {
ProfileBypassRulesDialog,
@@ -57,6 +74,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -80,24 +99,35 @@ import {
} from "@/components/ui/tooltip";
import { useBrowserState } from "@/hooks/use-browser-state";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useScrollFade } from "@/hooks/use-scroll-fade";
import { useTableSorting } from "@/hooks/use-table-sorting";
import { useTeamLocks } from "@/hooks/use-team-locks";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import {
getBrowserDisplayName,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import {
type CookieBotSchedule,
cancelCookieBotRun,
deleteCookieBotSchedule,
runCookieBotNow,
} from "@/lib/cookie-bot";
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
import { canUseCookieBot } from "@/lib/entitlements";
import { formatRelativeTime } from "@/lib/flag-utils";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
ExtensionGroup,
LocationItem,
ProfileBotState,
ProxyCheckResult,
StoredProxy,
SyncSessionInfo,
@@ -252,8 +282,52 @@ interface TableMeta {
}
| undefined;
onLaunchWithSync: (profile: BrowserProfile) => void;
// Cookie Bot
cookieBotUnlocked: boolean;
/** Narrow container: the bot column shows its state mark without the label. */
cookieBotCompact: boolean;
getProfileBotState: (profileId: string) => ProfileBotState;
/** A run this desktop has just asked for, before the stream confirms it. */
botPendingProfiles: Set<string>;
onBotEnrol: (profile: BrowserProfile) => void;
onBotEdit: (profile: BrowserProfile, schedule: CookieBotSchedule) => void;
onBotRunNow: (profile: BrowserProfile) => void;
onBotStopRun: (runId: string) => void;
onBotViewActivity: (profile: BrowserProfile) => void;
onBotUnenrol: (profile: BrowserProfile) => void;
/**
* Perform the repair a failed preflight names, or null when this surface has
* no way to reach it. A reason with no affordance is what the menu showed
* before: "No proxy or VPN · Attach a proxy" as inert label text that reads
* like a button and answers no click.
*/
onBotFix: ((profile: BrowserProfile, fix: PreflightFix) => void) | null;
}
/**
* Below this container width the bot column keeps its state mark but drops the
* "next run" label: an operator still sees at a glance which rows are enrolled
* and which are running, and the row menu stays reachable, without taking the
* width the name needs.
*/
const BOT_LABEL_WIDTH = 880;
/** Below this the bot column leaves entirely, like the other low-priority ones. */
const BOT_COLUMN_MIN_WIDTH = 400;
/** Bulk enrolments of this size or larger are confirmed, as run and stop are. */
const BULK_ENROL_CONFIRM_THRESHOLD = 10;
/**
* Run statuses that mean the browser never came up.
*
* `POST /cookie-bot/runs` answers 202 with the run row it recorded, so a
* refusal no capacity, no sites, a profile someone else has open arrives as
* a successful response carrying a terminal status.
*/
const RUN_DID_NOT_START = new Set(["skipped", "failed", "cancelled"]);
interface SyncStatusDot {
color: string;
tooltip: string;
@@ -1117,6 +1191,196 @@ const NoteCell = React.memo<{
NoteCell.displayName = "NoteCell";
/** `HH:MM` of the server's own next-run instant, in this machine's locale. */
function formatNextRun(schedule: CookieBotSchedule): string | null {
if (!schedule.next_run_at) return null;
const date = new Date(schedule.next_run_at);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
/**
* One row's Cookie Bot state, and the row's bot actions.
*
* The whole cell is the menu trigger. A dedicated kebab would cost another
* column of width in a table that is already dense, and the state mark is
* exactly the thing an operator reaches for when they want to change it. The
* dot, the tone and the phase wording all come from the shared status
* vocabulary, so a run cannot read one way here and another on the Cookie Bot
* page.
*/
const BotCell = React.memo<{
profile: BrowserProfile;
meta: TableMeta;
}>(({ profile, meta }) => {
// Own `t` rather than `meta.t`: the shared status helpers take a real
// `TFunction`, and every other cell in this file resolves it the same way.
const { t } = useTranslation();
const { schedule, liveSession } = meta.getProfileBotState(profile.id);
const check = preflight(profile);
const isPending = meta.botPendingProfiles.has(profile.id);
const isLive = liveSession !== null;
const nextRun = schedule ? formatNextRun(schedule) : null;
// The server computes "why tonight would be refused" on every read, precisely
// so a detached proxy is visible in the afternoon rather than announcing
// itself as a skipped run at 02:00. Dropping it left a broken enrolment
// showing a healthy dot and a next-run time it could never keep.
const blockedReason =
schedule?.enabled && schedule.blocked_by
? outcomeLabel(t, schedule.blocked_by)
: null;
// `provisioning` is a transfer in progress — the one meaning the app already
// reserves a pulsing dot for. Nothing else pulses.
const isPreparing = liveSession?.state === "provisioning" || isPending;
const tone = liveSession
? sessionTone(liveSession)
: isPending
? "warning"
: schedule
? schedule.enabled && !blockedReason
? "muted"
: "warning"
: null;
const label = isPending
? t("cookieBot.status.provisioning")
: liveSession
? sessionPhaseLabel(t, liveSession)
: schedule
? !schedule.enabled
? t("cookieBot.state.paused")
: (blockedReason ?? nextRun ?? t("cookieBot.state.enrolled"))
: "—";
const summary = schedule
? blockedReason
? t("cookieBot.state.blocked", { reason: blockedReason })
: t("cookieBot.state.summary", {
cadence: describeCadence(t, schedule.days_mask),
time: minutesToClock(schedule.run_at_minute),
})
: check.eligible
? t("cookieBot.state.notEnrolled")
: // The repair is its own menu item when this surface can reach it, so
// the label stays a statement instead of looking like a second button.
[
preflightReason(t, check),
meta.onBotFix ? null : preflightFixLabel(t, check.fix),
]
.filter(Boolean)
.join(" · ");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t("cookieBot.state.rowMenu", { name: profile.name })}
className="flex h-9 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded border-none bg-transparent px-1.5 text-left transition-colors duration-100 hover:bg-muted"
>
{tone ? (
<StatusDot tone={tone} pulse={isPreparing} className="size-1.5" />
) : (
<span aria-hidden="true" className="size-1.5 shrink-0" />
)}
{!meta.cookieBotCompact && (
<span
className={cn(
"min-w-0 truncate text-xs tabular-nums",
isLive || isPending
? "text-foreground"
: "text-muted-foreground",
)}
>
{label}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuLabel className="font-normal text-muted-foreground">
{summary}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{schedule ? (
<>
<DropdownMenuItem
onClick={() => {
meta.onBotEdit(profile, schedule);
}}
>
{t("cookieBot.actions.editSchedule")}
</DropdownMenuItem>
{liveSession?.run_id ? (
<DropdownMenuItem
onClick={() => {
if (liveSession.run_id) {
meta.onBotStopRun(liveSession.run_id);
}
}}
>
{t("cookieBot.running.stop")}
</DropdownMenuItem>
) : (
<DropdownMenuItem
disabled={isLive || isPending}
onClick={() => {
meta.onBotRunNow(profile);
}}
>
{t("cookieBot.actions.runNow")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
meta.onBotViewActivity(profile);
}}
>
{t("cookieBot.actions.viewActivity")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => {
meta.onBotUnenrol(profile);
}}
>
{t("cookieBot.schedule.unenrol")}
</DropdownMenuItem>
</>
) : (
<>
{!check.eligible && check.fix && meta.onBotFix && (
<DropdownMenuItem
onClick={() => {
meta.onBotFix?.(profile, check.fix as PreflightFix);
}}
>
{preflightFixLabel(t, check.fix)}
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={!check.eligible}
onClick={() => {
meta.onBotEnrol(profile);
}}
>
{t("cookieBot.actions.enrol")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
});
BotCell.displayName = "BotCell";
interface ProfilesDataTableProps {
profiles: BrowserProfile[];
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
@@ -1131,6 +1395,8 @@ interface ProfilesDataTableProps {
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
onAssignProfilesToGroup: (profileIds: string[]) => void;
/** Opens proxy assignment for a specific set of profiles. */
onAssignProfilesToProxy?: (profileIds: string[]) => void;
selectedGroupId: string | null;
selectedProfiles: string[];
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
@@ -1186,6 +1452,7 @@ export function ProfilesDataTable({
runningProfiles,
isUpdating,
onAssignProfilesToGroup,
onAssignProfilesToProxy,
selectedProfiles,
onSelectedProfilesChange,
onBulkDelete,
@@ -1319,6 +1586,36 @@ export function ProfilesDataTable({
const { user } = useCloudAuth();
const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id);
// Cookie Bot. Enrolments and live runs both live server-side, so the table
// reads them from the shared store rather than from BrowserProfile.
const cookieBotUnlocked = canUseCookieBot(user);
const {
scheduleFor,
liveSessionFor,
refresh: refreshCookieBotState,
} = useCookieBot(cookieBotUnlocked, cookieBotScopeFor(user));
const [botPendingProfiles, setBotPendingProfiles] = React.useState<
Set<string>
>(new Set());
const [botScheduleDialog, setBotScheduleDialog] = React.useState<{
profiles: BrowserProfile[];
existing: CookieBotSchedule | null;
} | null>(null);
const [botRunsProfile, setBotRunsProfile] =
React.useState<BrowserProfile | null>(null);
const [botUnenrolProfile, setBotUnenrolProfile] =
React.useState<BrowserProfile | null>(null);
const [isUnenrolling, setIsUnenrolling] = React.useState(false);
const [pendingBulkEnrol, setPendingBulkEnrol] = React.useState<
BrowserProfile[] | null
>(null);
// Content columns grow proportionally with the container but never drop
// below the compact-layout floor; the name column takes the remainder.
// Computed in px from the observed container width because fixed table
// layout ignores max()/calc() column widths.
const [containerWidth, setContainerWidth] = React.useState(0);
const [proxyOverrides, setProxyOverrides] = React.useState<
Record<string, string | null>
>({});
@@ -1512,6 +1809,142 @@ export function ProfilesDataTable({
[handleProxySelection],
);
const getProfileBotState = React.useCallback(
(profileId: string): ProfileBotState => ({
schedule: scheduleFor(profileId),
liveSession: liveSessionFor(profileId),
}),
[scheduleFor, liveSessionFor],
);
const handleBotEnrol = React.useCallback((profile: BrowserProfile) => {
setBotScheduleDialog({ profiles: [profile], existing: null });
}, []);
const handleBotEdit = React.useCallback(
(profile: BrowserProfile, schedule: CookieBotSchedule) => {
setBotScheduleDialog({ profiles: [profile], existing: schedule });
},
[],
);
const handleBotViewActivity = React.useCallback((profile: BrowserProfile) => {
setBotRunsProfile(profile);
}, []);
const handleBotFix = React.useCallback(
(profile: BrowserProfile, fix: PreflightFix) => {
if (fix === "proxy") {
onAssignProfilesToProxy?.([profile.id]);
return;
}
if (fix === "syncSettings") {
onOpenProfileSyncDialog?.(profile);
return;
}
void enableProfileSync(profile.id).catch((error: unknown) => {
showErrorToast(
parseBackendError(error)
? translateBackendError(t as never, error)
: t("cookieBot.preflight.fixFailed"),
);
});
},
[onAssignProfilesToProxy, onOpenProfileSyncDialog, t],
);
// Null rather than a no-op when nothing is wired: the menu then states the
// reason without offering a repair it cannot perform.
const botFixHandler =
onAssignProfilesToProxy || onOpenProfileSyncDialog ? handleBotFix : null;
const handleBotRunNow = React.useCallback(
async (profile: BrowserProfile) => {
// Held locally until the stream reports the session: the run is real the
// moment the command returns, and a row that still looks idle invites a
// second click that would spend a second hour.
setBotPendingProfiles((prev) => new Set(prev).add(profile.id));
try {
const started = await runCookieBotNow(profile.id);
// 202, not 200: the route answers with a RECORDED run, and a run that
// could not get a host comes back already terminal, carrying an
// `outcome_code`, rather than as an HTTP error. Treating every 2xx as
// "started" told a user their run had begun on a night when every
// Windows host in a four-slot fleet was busy, and the only trace was a
// row in a history panel they had to go and open.
if (RUN_DID_NOT_START.has(started.run.status)) {
showErrorToast(
t("cookieBot.actions.runNotStarted", {
reason:
outcomeLabel(t as never, started.run.outcome_code) ??
runStatusLabel(t as never, started.run.status),
}),
);
} else {
showSuccessToast(t("cookieBot.actions.runStarted"));
}
await refreshCookieBotState();
} catch (error) {
showErrorToast(translateBackendError(t as never, error));
} finally {
setBotPendingProfiles((prev) => {
const next = new Set(prev);
next.delete(profile.id);
return next;
});
}
},
[refreshCookieBotState, t],
);
const handleBotStopRun = React.useCallback(
async (runId: string) => {
try {
await cancelCookieBotRun(runId);
showSuccessToast(t("cookieBot.running.stopped"));
await refreshCookieBotState();
} catch (error) {
showErrorToast(translateBackendError(t as never, error));
}
},
[refreshCookieBotState, t],
);
const handleBotUnenrol = React.useCallback(async () => {
if (!botUnenrolProfile) return;
setIsUnenrolling(true);
try {
await deleteCookieBotSchedule(botUnenrolProfile.id);
showSuccessToast(t("cookieBot.schedule.unenrolled"));
setBotUnenrolProfile(null);
await refreshCookieBotState();
} catch (error) {
showErrorToast(translateBackendError(t as never, error));
} finally {
setIsUnenrolling(false);
}
}, [botUnenrolProfile, refreshCookieBotState, t]);
const handleBulkCookieBotEnrol = React.useCallback(() => {
const targets = profiles.filter((p) => selectedProfiles.includes(p.id));
if (targets.length === 0) return;
const eligible = targets.filter((p) => preflight(p).eligible);
// Same guard as bulk run: an action that can touch nothing says so instead
// of opening a dialog whose only outcome is a refusal.
if (eligible.length === 0) {
showErrorToast(t("cookieBot.actionBar.noneEligible"));
return;
}
// Ten or more is the threshold bulk run and stop already use, and enrolling
// is the heavier commitment of the three: each row books a nightly job
// against a shared budget.
if (eligible.length >= BULK_ENROL_CONFIRM_THRESHOLD) {
setPendingBulkEnrol(targets);
return;
}
setBotScheduleDialog({ profiles: targets, existing: null });
}, [profiles, selectedProfiles, t]);
// Use shared browser state hook
const browserState = useBrowserState(
profiles,
@@ -2019,6 +2452,23 @@ export function ProfilesDataTable({
(() => {
/* empty */
}),
// Cookie Bot
cookieBotUnlocked,
cookieBotCompact: containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH,
getProfileBotState,
botPendingProfiles,
onBotEnrol: handleBotEnrol,
onBotEdit: handleBotEdit,
onBotRunNow: (profile: BrowserProfile) => {
void handleBotRunNow(profile);
},
onBotStopRun: (runId: string) => {
void handleBotStopRun(runId);
},
onBotViewActivity: handleBotViewActivity,
onBotUnenrol: setBotUnenrolProfile,
onBotFix: botFixHandler,
}),
[
t,
@@ -2076,6 +2526,16 @@ export function ProfilesDataTable({
getLockInfo,
getProfileSyncInfo,
onLaunchWithSync,
cookieBotUnlocked,
containerWidth,
getProfileBotState,
botPendingProfiles,
handleBotEnrol,
handleBotEdit,
handleBotRunNow,
handleBotStopRun,
handleBotViewActivity,
botFixHandler,
],
);
@@ -2976,6 +3436,19 @@ export function ProfilesDataTable({
return <DnsCell profile={profile} meta={meta} />;
},
},
{
id: "bot",
size: 84,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
if (meta.cookieBotCompact) return null;
return meta.t("profiles.table.bot");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
return <BotCell profile={row.original} meta={meta} />;
},
},
{
id: "sync",
header: "",
@@ -3053,14 +3526,11 @@ export function ProfilesDataTable({
// Low-priority columns leave the table as the container narrows (most
// expendable first); their data stays reachable via the profile info
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
// `bot` starts hidden and is switched on by the resize effect below. An
// unentitled account must never see a paid column, not even for the frame
// before the observer's first measurement lands.
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({ created_at: false });
// Content columns grow proportionally with the container but never drop
// below the compact-layout floor; the name column takes the remainder.
// Computed in px from the observed container width because fixed table
// layout ignores max()/calc() column widths.
const [containerWidth, setContainerWidth] = React.useState(0);
React.useState<VisibilityState>({ created_at: false, bot: false });
const table = useReactTable({
data: profiles,
@@ -3090,6 +3560,14 @@ export function ProfilesDataTable({
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
const columnWidth = React.useCallback(
(id: string, sizePx: number) => {
// The bot column is the one column with two shapes: a labelled state at
// full width, a bare mark when the table is narrow. Taking a proportion
// in the compact shape would waste the space the name column needs.
if (id === "bot") {
return containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH
? "28px"
: `${Math.max(84, Math.round(containerWidth * 0.09))}px`;
}
const proportions: Record<string, { pct: number; floor: number }> = {
tags: { pct: 0.12, floor: 100 },
note: { pct: 0.1, floor: 80 },
@@ -3120,6 +3598,10 @@ export function ProfilesDataTable({
ext: w >= 672,
note: w >= 576,
tags: w >= 512,
// Bot state survives further down than the other content columns:
// by then it is a 28px mark, and it is the only place a row's
// enrolment and its actions can be reached.
bot: cookieBotUnlocked && w >= BOT_COLUMN_MIN_WIDTH,
};
return Object.keys(next).every((k) => prev[k] === next[k])
? prev
@@ -3132,7 +3614,7 @@ export function ProfilesDataTable({
return () => {
ro.disconnect();
};
}, []);
}, [cookieBotUnlocked]);
// Compact 36px row from the redesign spec; estimateSize must match the
// actual rendered row height or virtualizer placement drifts under scroll.
@@ -3508,6 +3990,23 @@ export function ProfilesDataTable({
<LuCookie />
</DataTableActionBarAction>
)}
<span className="relative inline-flex">
<DataTableActionBarAction
tooltip={
cookieBotUnlocked
? t("cookieBot.actionBar.enrol")
: t("cookieBot.actionBar.proRequired")
}
onClick={cookieBotUnlocked ? handleBulkCookieBotEnrol : undefined}
disabled={!cookieBotUnlocked}
size="icon"
>
<LuMoon />
</DataTableActionBarAction>
{!cookieBotUnlocked && (
<ProBadge className="pointer-events-none absolute -top-2 -right-2" />
)}
</span>
{onBulkDelete && (
<DataTableActionBarAction
tooltip={t("common.buttons.delete")}
@@ -3554,6 +4053,85 @@ export function ProfilesDataTable({
profileId={launchHookProfile?.id ?? null}
currentLaunchHook={launchHookProfile?.launch_hook ?? null}
/>
{botScheduleDialog && (
<CookieBotEnrolDialog
isOpen
onClose={() => {
setBotScheduleDialog(null);
}}
profiles={botScheduleDialog.profiles}
existing={botScheduleDialog.existing}
onOpenProfileSync={onOpenProfileSyncDialog}
// "A proxy or VPN is required" is the precondition most profiles
// fail, and without this the dialog showed the reason with no way to
// act on it — one-click fixable from the Cookie Bot page and a dead
// end from the row menu that is the primary entry point.
onAssignProxy={onAssignProfilesToProxy}
onSaved={() => {
// Clearing after a bulk write mirrors the other bulk actions: the
// selection has been acted on, and leaving it live invites a second
// pass over profiles that are already enrolled.
if (botScheduleDialog.profiles.length > 1) {
onSelectedProfilesChange([]);
}
}}
/>
)}
<DeleteConfirmationDialog
isOpen={pendingBulkEnrol !== null}
onClose={() => {
setPendingBulkEnrol(null);
}}
onConfirm={() => {
if (!pendingBulkEnrol) return;
setBotScheduleDialog({
profiles: pendingBulkEnrol,
existing: null,
});
setPendingBulkEnrol(null);
}}
title={t("cookieBot.enrol.confirmBulkTitle", {
count:
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
})}
description={t("cookieBot.enrol.confirmBulkDescription", {
count:
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
})}
confirmButtonText={t("cookieBot.enrol.confirmBulkButton", {
count:
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
})}
confirmButtonVariant="default"
profileIds={pendingBulkEnrol
?.filter((p) => preflight(p).eligible)
.map((p) => p.id)}
profiles={pendingBulkEnrol?.map((p) => ({ id: p.id, name: p.name }))}
/>
<CookieBotRunsDialog
isOpen={botRunsProfile !== null}
onClose={() => {
setBotRunsProfile(null);
}}
profileId={botRunsProfile?.id ?? null}
profileName={botRunsProfile?.name}
onRunCancelled={() => {
void refreshCookieBotState();
}}
/>
<DeleteConfirmationDialog
isOpen={botUnenrolProfile !== null}
onClose={() => {
setBotUnenrolProfile(null);
}}
onConfirm={handleBotUnenrol}
title={t("cookieBot.schedule.unenrolTitle", {
name: botUnenrolProfile?.name ?? "",
})}
description={t("cookieBot.schedule.unenrolDescription")}
confirmButtonText={t("cookieBot.schedule.unenrol")}
isLoading={isUnenrolling}
/>
</>
);
}
+1 -2
View File
@@ -2034,8 +2034,7 @@ function SecuritySectionInline({
}
if (mode === "set" || mode === "change") {
if (password.length < 8) return t("profilePassword.errors.tooShort");
if (password !== confirm)
return t("profilePassword.errors.passwordMismatch");
if (password !== confirm) return t("profilePassword.errors.mismatch");
}
return null;
};
+21 -1
View File
@@ -8,6 +8,7 @@ import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal } from "react-icons/go";
import {
LuCloud,
LuCookie,
LuInfo,
LuKeyboard,
LuPlug,
@@ -26,6 +27,7 @@ export type AppPage =
| "proxies"
| "extensions"
| "groups"
| "cookieBot"
| "vpns"
| "settings"
| "integrations"
@@ -174,6 +176,12 @@ interface RailNavProps {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
onOpenAbout: () => void;
/**
* A remote session is running right now. The Cookie Bot item carries a dot so
* the state is legible from every other page an overnight job you cannot
* see from where you are standing may as well not be observable at all.
*/
cookieBotRunning?: boolean;
}
/** Shared-element indicator that slides between the active rail items. */
@@ -199,6 +207,7 @@ const TOP_ITEMS: RailItem[] = [
{ page: "proxies", Icon: FiWifi, labelKey: "rail.network" },
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
{ page: "cookieBot", Icon: LuCookie, labelKey: "rail.cookieBot" },
{ page: "integrations", Icon: LuPlug, labelKey: "rail.integrations" },
{ page: "account", Icon: LuCloud, labelKey: "rail.account" },
];
@@ -229,6 +238,7 @@ export function RailNav({
currentPage,
onNavigate,
onOpenAbout,
cookieBotRunning = false,
}: RailNavProps) {
const { t } = useTranslation();
const [moreOpen, setMoreOpen] = useState(false);
@@ -325,9 +335,19 @@ export function RailNav({
>
{active && <ActiveIndicator />}
<Icon className="size-3.5" />
{page === "cookieBot" && cookieBotRunning && (
<span
aria-hidden="true"
className="absolute top-1 right-1 size-1.5 rounded-full bg-success"
/>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
<TooltipContent side="right">
{page === "cookieBot" && cookieBotRunning
? t("rail.cookieBotRunning")
: t(labelKey)}
</TooltipContent>
</Tooltip>
);
})}
+470
View File
@@ -0,0 +1,470 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type {
NameType,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { formatHours, RemoteHoursMeter } from "@/components/cookie-bot-shared";
import { AnimatedDisclosureItem } from "@/components/ui/animated-disclosure";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { translateBackendError } from "@/lib/backend-errors";
import {
type CookieBotUsage,
type CookieBotUsageMember,
getCookieBotUsage,
type RemoteHoursQuota,
} from "@/lib/cookie-bot";
import { MOTION_EASE_OUT } from "@/lib/motion";
import { cn } from "@/lib/utils";
/** How many past billing periods the selector offers. */
const PERIOD_COUNT = 6;
function periodKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
}
function recentPeriods(): { value: string; label: string }[] {
const now = new Date();
return Array.from({ length: PERIOD_COUNT }, (_, index) => {
const date = new Date(now.getFullYear(), now.getMonth() - index, 1);
return {
value: periodKey(date),
label: date.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
}),
};
});
}
/** The part of an address that identifies the person, for a dense axis. */
function shortName(email: string): string {
const local = email.split("@")[0] ?? email;
return local.length > 14 ? `${local.slice(0, 13)}` : local;
}
interface MemberDatum {
name: string;
email: string;
bot: number;
interactive: number;
total: number;
runs: number;
/** How many of those runs did not do what they were asked. */
runsFailed: number;
sessions: number;
}
interface TeamUsagePanelProps {
/**
* The live pooled budget. Only used before the selected period's own figures
* arrive, so the block is never empty on first paint; once `usage` lands the
* period's numbers win, because looking at June must show what June allowed
* rather than what is left today.
*/
quota?: RemoteHoursQuota | null;
className?: string;
}
/**
* Who spent the pooled remote hours this period.
*
* The account page owns plan truth what was bought and this is the other
* half of that: what it was spent on and by whom. Every figure is served; the
* desktop computes no allowance and no share of one.
*/
export function TeamUsagePanel({ quota, className }: TeamUsagePanelProps) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const periods = React.useMemo(() => recentPeriods(), []);
const [period, setPeriod] = React.useState(periods[0].value);
const [usage, setUsage] = React.useState<CookieBotUsage | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
setIsLoading(true);
setError(null);
void getCookieBotUsage(period)
.then((result) => {
if (active) setUsage(result);
})
.catch((err: unknown) => {
if (active) {
setUsage(null);
setError(translateBackendError(t as never, err));
}
})
.finally(() => {
if (active) setIsLoading(false);
});
return () => {
active = false;
};
}, [period, t]);
// Heaviest first: the whole point of the view is to make the biggest
// consumer the first thing read, both in the chart and in the table.
const members: MemberDatum[] = React.useMemo(() => {
if (!usage) return [];
return [...usage.members]
.sort(
(a: CookieBotUsageMember, b: CookieBotUsageMember) =>
b.used_hours - a.used_hours,
)
.map((member) => ({
name: shortName(member.email),
email: member.email,
bot: member.bot_hours,
interactive: member.interactive_hours,
total: member.used_hours,
runs: member.bot_runs,
runsFailed: member.bot_runs_failed,
sessions: member.sessions,
}));
}, [usage]);
const heaviest = members[0]?.total ?? 0;
const isSolo = members.length <= 1;
// The meter takes a quota shape; the usage response carries the same numbers
// for the period being looked at, so it is adapted rather than
// re-implemented. The live quota is only the stand-in until it arrives.
const pooled: RemoteHoursQuota | null = React.useMemo(
() =>
usage
? {
granted_hours: usage.granted_hours,
used_hours: usage.used_hours,
remaining_hours: usage.remaining_hours,
period_start: usage.period_start,
period_end: usage.period_end,
team_id: usage.team_id,
seats: usage.seats,
per_seat_hours: 0,
members: [],
}
: (quota ?? null),
[usage, quota],
);
const renderTooltip = React.useCallback(
({ active, payload }: TooltipContentProps<ValueType, NameType>) => {
if (!active || !payload || payload.length === 0) return null;
const datum = payload[0].payload as MemberDatum;
return (
<div className="rounded-md border border-border bg-popover px-2.5 py-2 text-xs text-popover-foreground shadow-sm">
<p className="font-medium">{datum.email}</p>
<p className="mt-1 flex items-center justify-between gap-4 tabular-nums">
<span className="text-chart-1">
{t("cookieBot.team.legendBot")}
</span>
<span>
{t("cookieBot.team.hours", { hours: formatHours(datum.bot) })}
</span>
</p>
<p className="flex items-center justify-between gap-4 tabular-nums">
<span className="text-chart-2">
{t("cookieBot.team.legendInteractive")}
</span>
<span>
{t("cookieBot.team.hours", {
hours: formatHours(datum.interactive),
})}
</span>
</p>
</div>
);
},
[t],
);
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium">{t("cookieBot.team.title")}</h3>
<Select
value={period}
onValueChange={(value) => {
setPeriod(value);
}}
>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{periods.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Driven by the selected period's own figures, not by the live quota:
choosing June must show what June was allowed, not what is left
today. */}
<div className="flex flex-col gap-1.5">
{pooled ? (
<p className="text-xs tabular-nums text-muted-foreground">
{t("cookieBot.team.pooled", {
used: formatHours(pooled.used_hours),
total: formatHours(pooled.granted_hours),
// `count` (not `seats`) so i18next can pluralise: "1 seat" and
// "across 4 seats" are different sentences in most locales.
count: pooled.seats,
})}
</p>
) : (
<Skeleton className="h-3 w-56" />
)}
<RemoteHoursMeter
quota={pooled}
isLoading={isLoading && usage === null}
variant="inline"
/>
</div>
{error ? (
<p className="py-6 text-center text-xs text-destructive-text">
{error}
</p>
) : isLoading && !usage ? (
<div className="space-y-2">
<Skeleton className="h-[180px] w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
) : members.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">
{t("cookieBot.team.noActivity")}
</p>
) : (
<>
{!isSolo && (
<>
<div className="h-[clamp(160px,22vh,240px)] w-full">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
<AreaChart
data={members}
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="cookieBotHoursGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="interactiveHoursGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-2)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-2)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="name"
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
interval={0}
/>
<YAxis
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
width={40}
/>
<Tooltip content={renderTooltip} />
{/* `linear` because the axis is a ranking, not time: a
monotone curve would invent values between people. */}
<Area
type="linear"
dataKey="bot"
stackId="1"
stroke="var(--chart-1)"
fill="url(#cookieBotHoursGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
<Area
type="linear"
dataKey="interactive"
stackId="1"
stroke="var(--chart-2)"
fill="url(#interactiveHoursGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<div
className="size-2.5 rounded"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">
{t("cookieBot.team.legendBot")}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="size-2.5 rounded"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">
{t("cookieBot.team.legendInteractive")}
</span>
</div>
</div>
</>
)}
{isSolo && (
<p className="text-xs text-muted-foreground">
{t("cookieBot.team.soloNote")}
</p>
)}
<div className="overflow-hidden rounded-md border border-border">
<div className="grid grid-cols-[1fr_auto_auto_5rem] items-center gap-3 border-b border-border bg-muted/40 px-3 py-1.5 text-[10px] tracking-wide text-muted-foreground uppercase">
<span>{t("cookieBot.team.columnMember")}</span>
<span className="text-right">
{t("cookieBot.team.columnRuns")}
</span>
<span className="text-right">
{t("cookieBot.team.columnHours")}
</span>
<span className="text-right">
{t("cookieBot.team.columnShare")}
</span>
</div>
{members.map((member, index) => (
// The ranking genuinely re-orders when the period changes, so the
// rows travel to their new places instead of teleporting. Layout
// only — the row is fully rendered and readable on first paint.
<AnimatedDisclosureItem
key={member.email}
className="grid grid-cols-[1fr_auto_auto_5rem] items-center gap-3 px-3 py-1.5 text-xs"
>
<span
className={cn(
"min-w-0 truncate",
index === 0 ? "font-medium text-foreground" : "",
)}
title={member.email}
>
{member.email}
</span>
{/* The failure count is already on the wire and answers the
question the run count cannot: whether the hours bought
anything. */}
<span className="text-right tabular-nums text-muted-foreground">
{member.runs}
{member.runsFailed > 0 && (
<span className="ml-1 text-warning-text">
{t("cookieBot.team.runsFailed", {
n: member.runsFailed,
})}
</span>
)}
</span>
<span
className={cn(
"text-right tabular-nums",
index === 0
? "font-semibold text-foreground"
: "text-muted-foreground",
)}
>
{t("cookieBot.team.hours", {
hours: formatHours(member.total),
})}
</span>
<span className="h-1 overflow-hidden rounded-full bg-muted">
{/* Radius on the track, scale on the fill: a rounded cap that
is being scaled flips shape mid-transition. `initial=
{false}` keeps the first paint at the true share. */}
<motion.span
initial={false}
animate={{
scaleX: heaviest > 0 ? member.total / heaviest : 0,
}}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.22, ease: MOTION_EASE_OUT }
}
style={{ transformOrigin: "left", willChange: "transform" }}
className={cn(
"block h-full w-full rounded-full",
index === 0 ? "bg-foreground" : "bg-muted-foreground",
)}
/>
</span>
</AnimatedDisclosureItem>
))}
</div>
</>
)}
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { useCallback, useEffect, useId, useSyncExternalStore } from "react";
import {
type CookieBotSchedule,
type CookieBotScope,
getCookieBotSchedules,
getRemoteHoursQuota,
type RemoteHoursQuota,
} from "@/lib/cookie-bot";
import {
isSessionOver,
onRemoteSessionSnapshot,
onRemoteSessionState,
onRemoteSessionStream,
type RemoteSessionState,
startRemoteSessionEvents,
stopRemoteSessionEvents,
} from "@/lib/remote-sessions";
/**
* One shared read of the cookie-bot plane.
*
* Several surfaces need the same three facts at once the profile table, the
* account page, the enrolment dialog and each is mounted independently. A
* per-component fetch would mean three requests on open and three different
* answers after an edit, so the state lives in one module store and every
* consumer subscribes to it.
*
* `POST /api/remote-sessions` answers `provisioning` and nothing more, so a
* live run is only observable through the event stream. This store is what
* starts that stream on the desktop: without it a signed-in user is blind
* between launch and stop.
*/
export interface CookieBotSnapshot {
/** Enrolments by profile id. */
schedules: Record<string, CookieBotSchedule>;
/** Remote sessions that have not closed yet, by profile id. */
liveSessions: Record<string, RemoteSessionState>;
/** The pooled remote-hour budget, or null before the first answer. */
quota: RemoteHoursQuota | null;
/** True only while the first load of a newly enabled session is in flight. */
isLoading: boolean;
/** The last load failure, as a backend error code envelope or message. */
error: string | null;
/** Whether transitions are currently arriving. */
streamConnected: boolean;
}
const EMPTY: CookieBotSnapshot = {
schedules: {},
liveSessions: {},
quota: null,
isLoading: false,
error: null,
streamConnected: false,
};
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: CookieBotSnapshot = EMPTY;
let subscriberCount = 0;
let unlisteners: (() => void)[] = [];
let attachGeneration = 0;
let enabled = false;
let scope: CookieBotScope = "mine";
let loadToken = 0;
function emit(next: Partial<CookieBotSnapshot>) {
snapshot = { ...snapshot, ...next };
for (const listener of listeners) listener();
}
function getSnapshot(): CookieBotSnapshot {
return snapshot;
}
/** Sessions still in flight, keyed by the profile they are warming. */
function indexOpenSessions(
sessions: RemoteSessionState[],
): Record<string, RemoteSessionState> {
const next: Record<string, RemoteSessionState> = {};
for (const session of sessions) {
if (!session.profile_id || isSessionOver(session)) continue;
next[session.profile_id] = session;
}
return next;
}
async function attachStream() {
// Idempotent on the Rust side; calling it here is what covers the user who
// signs in without restarting the app.
try {
await startRemoteSessionEvents();
} catch (error) {
// A signed-out or unentitled desktop refuses the subscription. That is not
// a UI failure — the rest of the state still renders — so it is logged and
// the stream simply stays disconnected.
console.error("Failed to subscribe to remote session events:", error);
}
}
async function attachListeners() {
// `listen()` resolves a tick later, and subscribe/unsubscribe can both happen
// before it does (React runs mount effects twice in development). Without
// this generation check the first attach would install its handlers after the
// detach had already run, leaking a listener that no unsubscribe can reach
// and double-emitting every session transition for the rest of the session.
const generation = ++attachGeneration;
const offs = await Promise.all([
onRemoteSessionState((session) => {
if (!session.profile_id) return;
const over = isSessionOver(session);
const next = { ...snapshot.liveSessions };
if (over) {
delete next[session.profile_id];
} else {
next[session.profile_id] = session;
}
emit({ liveSessions: next });
// A run that just ended has spent hours and may have moved the
// schedule's next slot, so both are re-read rather than guessed at.
if (over) void refreshCookieBot();
}),
onRemoteSessionSnapshot((payload) => {
emit({ liveSessions: indexOpenSessions(payload.sessions) });
}),
onRemoteSessionStream((status) => {
emit({ streamConnected: status.connected });
}),
]);
if (generation !== attachGeneration) {
for (const off of offs) off();
return;
}
unlisteners = offs;
}
function detachListeners() {
attachGeneration += 1;
for (const off of unlisteners) off();
unlisteners = [];
}
async function load() {
const token = ++loadToken;
emit({ isLoading: snapshot.quota === null, error: null });
const [schedules, quota] = await Promise.allSettled([
getCookieBotSchedules(scope),
getRemoteHoursQuota(),
]);
// A later load (or a sign-out) has already superseded this one.
if (token !== loadToken || !enabled) return;
const next: Partial<CookieBotSnapshot> = { isLoading: false };
if (schedules.status === "fulfilled") {
const byProfile: Record<string, CookieBotSchedule> = {};
for (const schedule of schedules.value.schedules) {
byProfile[schedule.profile_id] = schedule;
}
next.schedules = byProfile;
}
if (quota.status === "fulfilled") next.quota = quota.value;
// Both failing is a real outage worth surfacing; one failing leaves the
// other half of the screen correct, which beats blanking everything.
if (schedules.status === "rejected" && quota.status === "rejected") {
next.error = String(schedules.reason);
} else {
next.error = null;
}
emit(next);
}
/** Re-read schedules and the quota. Call after any write. */
export async function refreshCookieBot(): Promise<void> {
if (!enabled) return;
await load();
}
function reconcile(nextEnabled: boolean, nextScope: CookieBotScope) {
const scopeChanged = nextScope !== scope;
scope = nextScope;
if (nextEnabled === enabled) {
if (nextEnabled && scopeChanged) void load();
return;
}
enabled = nextEnabled;
if (enabled) {
void attachStream();
void load();
} else {
loadToken++;
void stopRemoteSessionEvents().catch((error: unknown) => {
console.error("Failed to unsubscribe from remote session events:", error);
});
snapshot = EMPTY;
for (const listener of listeners) listener();
}
}
/**
* What each mounted consumer wants. The store is a singleton with several
* consumers whose lifetimes differ the shell holds it open for the whole
* session, a dialog only while it is on screen so the wish is a union, never
* last-writer-wins. Without this, closing the enrol dialog (which passes
* `isOpen && entitled`) would tear down the event stream the shell and the
* profile table are still reading from.
*/
const wishes = new Map<string, { enabled: boolean; scope: CookieBotScope }>();
let applyScheduled = false;
function applyWishes() {
let nextEnabled = false;
let nextScope: CookieBotScope = "mine";
for (const wish of wishes.values()) {
if (wish.enabled) nextEnabled = true;
if (wish.scope === "team") nextScope = "team";
}
reconcile(nextEnabled, nextScope);
}
/**
* React runs an effect's cleanup and its next body back to back, so a consumer
* re-registering would otherwise be seen as a momentary "nobody wants this" and
* flush the whole snapshot. Coalescing into a microtask means only the settled
* state is ever acted on.
*/
function scheduleApplyWishes() {
if (applyScheduled) return;
applyScheduled = true;
queueMicrotask(() => {
applyScheduled = false;
applyWishes();
});
}
function subscribe(listener: Listener): () => void {
listeners.add(listener);
subscriberCount += 1;
if (subscriberCount === 1) void attachListeners();
return () => {
listeners.delete(listener);
subscriberCount -= 1;
if (subscriberCount === 0) detachListeners();
};
}
/**
* Which enrolments to read. Derive it here rather than at each call site: the
* store is shared, so two consumers asking for different scopes would refetch
* over each other, and the server refuses `team` from a caller with no team.
*/
export function cookieBotScopeFor(
user: { teamId?: string } | null | undefined,
): CookieBotScope {
return user?.teamId ? "team" : "mine";
}
export interface UseCookieBotResult extends CookieBotSnapshot {
refresh: () => Promise<void>;
/** This profile's enrolment, or null. */
scheduleFor: (profileId: string) => CookieBotSchedule | null;
/** An unfinished remote session for this profile, or null. */
liveSessionFor: (profileId: string) => RemoteSessionState | null;
}
/**
* @param isEnabled the user is signed in and entitled. False keeps the store
* idle so an unentitled desktop never polls a route that will refuse it.
* @param teamScope read the whole team's enrolments. Only pass `"team"` when
* the user actually belongs to one the server refuses it otherwise.
*/
export function useCookieBot(
isEnabled: boolean,
teamScope: CookieBotScope = "mine",
): UseCookieBotResult {
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const consumerId = useId();
useEffect(() => {
wishes.set(consumerId, { enabled: isEnabled, scope: teamScope });
scheduleApplyWishes();
return () => {
wishes.delete(consumerId);
scheduleApplyWishes();
};
}, [consumerId, isEnabled, teamScope]);
const scheduleFor = useCallback(
(profileId: string) => state.schedules[profileId] ?? null,
[state.schedules],
);
const liveSessionFor = useCallback(
(profileId: string) => state.liveSessions[profileId] ?? null,
[state.liveSessions],
);
return {
...state,
refresh: refreshCookieBot,
scheduleFor,
liveSessionFor,
};
}
+324 -7
View File
@@ -258,7 +258,8 @@
"emptyCreate": "Create profile",
"emptyImport": "Import profiles",
"emptyFilteredTitle": "No profiles found",
"emptyFilteredHint": "No profiles match this group or search. Try another filter or create a new one."
"emptyFilteredHint": "No profiles match this group or search. Try another filter or create a new one.",
"bot": "Bot"
},
"actions": {
"launch": "Launch",
@@ -1851,7 +1852,35 @@
"vlessConfigInvalid": "The VLESS URI is invalid.",
"xrayUnavailable": "Xray-core is unavailable on this system.",
"xrayUnsupportedOs": "VLESS requires macOS 12 or newer.",
"xrayStartFailed": "Xray-core could not start."
"xrayStartFailed": "Xray-core could not start.",
"cloudNotSignedIn": "Sign in to your Donut Browser account to use this.",
"cloudUnreachable": "Could not reach Donut Browser's servers. Check your connection and try again.",
"cloudRequestFailed": "The request failed. Please try again in a moment.",
"remoteRateLimited": "Too many requests. Wait a moment and try again.",
"remoteNoCapacity": "No remote host is free right now. Try again in a few minutes.",
"remoteNotEntitled": "Your plan does not include remote execution.",
"remoteSessionRefused": "The remote host refused this session.",
"remoteSessionNotFound": "That remote session no longer exists.",
"remoteSessionConflict": "This profile is already open somewhere else.",
"remoteSyncInProgress": "This profile is still uploading to cloud sync. Wait for the sync to finish, then try again.",
"remoteHoursExhausted": "You have used all {{used}} of your {{granted}} remote hours this month.",
"notTeamMember": "You are not a member of a team.",
"cookieBotNotEntitled": "Your plan does not include the cookie bot.",
"cookieBotNotEnrolled": "This profile is not set up for the cookie bot yet.",
"cookieBotScheduleConflict": "{{email}} already warms this profile at {{time}}.",
"cookieBotRunInProgress": "A run is already in progress for this profile.",
"cookieBotRunNotFound": "That run no longer exists.",
"cookieBotInvalidSchedule": "That schedule is not valid. Check the run time, days and duration.",
"cookieBotInvalidTimezone": "{{timezone}} is not a time zone the server recognises.",
"cookieBotInvalidPeriod": "That period is not valid. Use a month such as 2026-08.",
"cookieBotSiteLimit": "Enter between {{min}} and {{max}} sites, each a full http or https address.",
"cookieBotRequiresCloudSync": "Turn on cloud sync for this profile first — a remote host has no other way to obtain it.",
"cookieBotEncryptedSyncUnsupported": "This profile uses end-to-end encrypted sync, which a remote host cannot decrypt. Switch it to Regular sync.",
"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.",
"unknownCode": "Something went wrong: {{code}}",
"cookieBotTouchFingerprintUnsupported": "This profile claims a touch device, which the bot cannot drive. Use a desktop fingerprint."
},
"rail": {
"profiles": "Profiles",
@@ -1870,7 +1899,9 @@
},
"network": "Network",
"integrations": "Integrations",
"account": "Account"
"account": "Account",
"cookieBot": "Cookie Bot",
"cookieBotRunning": "Cookie Bot — running now"
},
"pageTitle": {
"proxies": "Network",
@@ -1881,7 +1912,8 @@
"integrations": "Integrations",
"account": "Account",
"import": "Import profile",
"shortcuts": "Keyboard shortcuts"
"shortcuts": "Keyboard shortcuts",
"cookieBot": "Cookie Bot"
},
"encryption": {
"required": {
@@ -1924,7 +1956,8 @@
},
"tabs": {
"account": "Account",
"selfHosted": "Self-hosted"
"selfHosted": "Self-hosted",
"teamUsage": "Team usage"
},
"selfHosted": {
"title": "Self-hosted sync server",
@@ -1937,7 +1970,8 @@
},
"deviceOrdinal": "{{ordinal}} of {{count}}",
"automationPrimaryOnly": "Browser automation runs only on your primary device (Device 1). Sign out there to use it here.",
"automationActiveHere": "Browser automation is active on this device."
"automationActiveHere": "Browser automation is active on this device.",
"viewTeamUsage": "View team usage"
},
"shortcutsPage": {
"title": "Keyboard shortcuts",
@@ -1970,7 +2004,8 @@
"goGroups": "Go to Groups",
"goIntegrations": "Go to Integrations",
"goAccount": "Go to Account",
"goSettings": "Go to Settings"
"goSettings": "Go to Settings",
"goCookieBot": "Cookie Bot"
},
"closeConfirm": {
"title": "Close Donut Browser?",
@@ -2102,5 +2137,287 @@
"matchToProxy": "Match fingerprint to proxy",
"matching": "Matching…",
"matchSuccess": "Fingerprint updated to match the proxy. Relaunch the profile to apply."
},
"cookieBot": {
"title": "Cookie Bot",
"description": "Overnight profile warming on a remote machine.",
"tabs": {
"overview": "Overview",
"schedule": "Schedule",
"activity": "Activity",
"team": "Team"
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot warms your profiles overnight on a remote machine, so they keep their cookies and their history without your computer being on. It needs a Pro or Team plan."
},
"empty": {
"title": "No profiles are enrolled",
"hint": "Pick a profile and the bot warms it overnight on a remote machine. Your computer can be off.",
"cta": "Enrol a profile"
},
"tonight": {
"label": "Tonight",
"nextRun": "Next run {{time}}",
"dueCount_one": "{{count}} profile due",
"dueCount_other": "{{count}} profiles due",
"nothingScheduled": "Nothing scheduled"
},
"lastDay": {
"label": "Last 24 hours",
"none": "No runs yet",
"ran_one": "{{count}} ran",
"ran_other": "{{count}} ran",
"partial_one": "{{count}} partial",
"partial_other": "{{count}} partial",
"failed_one": "{{count}} failed",
"failed_other": "{{count}} failed"
},
"chart": {
"machineTime": "Machine time per night",
"minutes": "{{minutes}} min"
},
"hours": {
"label": "Remote hours",
"remaining": "{{remaining}} h left of {{total}}",
"remainingOf": "of {{total}} h",
"used": "{{used}} of {{total}} h used",
"resets": "Resets {{date}}",
"exhausted": "No remote hours left. Schedules stay enrolled and resume next cycle.",
"exhaustedOn": "No remote hours left. Schedules stay enrolled and resume {{date}}.",
"estimate": "About {{hours}} h per week · {{remaining}} h left this cycle",
"estimateOverBudget": "Needs about {{hours}} h per week — only {{remaining}} h left",
"estimateOnly": "About {{hours}} h per week"
},
"enrolled": {
"columnProfile": "Profile",
"columnCadence": "Cadence",
"columnTime": "Time",
"columnNextRun": "Next run",
"columnLastRun": "Last run",
"enrolProfiles": "Enrol profiles",
"edit": "Edit schedule",
"neverRun": "Never",
"pausedNoHours": "Paused — out of hours",
"profileMissing": "That profile is not on this computer."
},
"schedule": {
"empty": "Nothing is scheduled yet.",
"quietHours_one": "{{count}} quiet hour",
"quietHours_other": "{{count}} quiet hours",
"crowded_one": "{{count}} profile starts at once",
"crowded_other": "{{count}} profiles start at once",
"unenrol": "Remove from Cookie Bot",
"unenrolTitle": "Remove {{name}} from Cookie Bot?",
"unenrolDescription": "It stops warming tonight. Past runs stay in Activity.",
"unenrolled": "Removed from Cookie Bot"
},
"status": {
"provisioning": "Preparing a machine",
"ready": "Loading profile",
"warming": "Warming",
"finished": "Finishing",
"failed": "Failed"
},
"runStatus": {
"pending": "Queued",
"running": "Running",
"succeeded": "Complete",
"partial": "Partial",
"failed": "Failed",
"skipped": "Skipped",
"cancelled": "Stopped"
},
"running": {
"label": "Running now",
"more": "+{{count}} more",
"stop": "Stop run",
"stopped": "Run stopped"
},
"live": {
"idle": "Nothing is running right now",
"streamOffline": "Live updates are offline",
"streamOfflineDetail": "Live updates are offline — reconnecting. The runs below may be out of date.",
"unnamedSession": "Remote session",
"notStartedYet": "The machine has not reported a start time yet.",
"sitesProgress": "{{visited}} of {{total}} sites",
"sitesUnknown": "Sites not reported yet",
"consentHandled_one": "{{count}} consent prompt handled",
"consentHandled_other": "{{count}} consent prompts handled",
"consentUnknown": "Consent prompts not reported yet",
"billed": "{{duration}} billed",
"billedUnknown": "Billed time not reported yet",
"chunk": "Part {{index}} of {{total}}"
},
"history": {
"title": "Runs",
"allProfiles": "All profiles",
"searchPlaceholder": "Search profiles…",
"filterAll": "All runs",
"filterComplete": "Complete",
"filterPartial": "Partial",
"filterFailed": "Failed",
"columnStarted": "Started",
"columnProfile": "Profile",
"columnDuration": "Duration",
"columnSites": "Sites",
"columnStatus": "Status",
"columnOperator": "Operator",
"empty": "No runs yet.",
"noMatch": "No runs match this filter.",
"unknownProfile": "Unknown profile",
"outcome": "Reason: {{reason}}",
"sitesVisited": "{{visited}}/{{total}}",
"sitesFailed_one": "{{count}} site could not be reached",
"sitesFailed_other": "{{count}} sites could not be reached",
"consentHandled_one": "{{count}} consent prompt handled",
"consentHandled_other": "{{count}} consent prompts handled",
"sitesUnknown": "Not reported"
},
"duration": {
"hm": "{{hours}}h {{minutes}}m",
"ms": "{{minutes}}m {{seconds}}s",
"s": "{{seconds}}s"
},
"picker": {
"title": "Enrol profiles",
"description": "Pick the profiles the bot should warm overnight.",
"searchPlaceholder": "Search profiles…",
"noProfiles": "No profiles match.",
"alreadyEnrolled": "Enrolled",
"continue_one": "Continue with {{count}}",
"continue_other": "Continue with {{count}}"
},
"enrol": {
"titleOne": "Enrol {{name}}",
"titleCount_one": "Enrol {{count}} profile",
"titleCount_other": "Enrol {{count}} profiles",
"editTitle": "Edit schedule",
"description": "The bot opens the profile on a remote machine and browses the sites you list. Your computer can be off.",
"summaryNightly": "Runs every night at {{time}}, up to {{minutes}} min each.",
"summaryWeeknights": "Runs Monday to Friday at {{time}}, up to {{minutes}} min each.",
"summaryAlternate": "Runs every other night at {{time}}, up to {{minutes}} min each.",
"summaryCustom_one": "Runs {{count}} night a week at {{time}}, up to {{minutes}} min each.",
"summaryCustom_other": "Runs {{count}} nights a week at {{time}}, up to {{minutes}} min each.",
"confirm": "Enrol tonight",
"confirmSome": "Enrol {{eligible}} of {{total}} tonight",
"fixFirst": "Fix these first",
"saving": "Enrolling…",
"saved": "Schedule saved",
"enrolled_one": "Enrolled {{count}} profile",
"enrolled_other": "Enrolled {{count}} profiles",
"adjust": "Adjust schedule",
"cadenceLabel": "Cadence",
"cadenceNightly": "Nightly",
"cadenceWeeknights": "Weeknights",
"cadenceAlternate": "Every other night",
"cadenceCustom_one": "{{count}} night a week",
"cadenceCustom_other": "{{count}} nights a week",
"timeLabel": "Start time",
"timeHint": "Local to each profile's fingerprint timezone.",
"maxMinutesLabel": "Max minutes",
"intensityLabel": "Depth",
"sitesLabel": "Sites",
"sitesPlaceholder": "One address per line",
"sitesHint_one": "{{count}} site. Only pages you list here are visited.",
"sitesHint_other": "{{count}} sites. Only pages you list here are visited.",
"sitesTooMany": "At most {{max}} sites.",
"presetsUnavailable": "Could not load the depth presets. Try again in a moment.",
"confirmBulkTitle_one": "Enrol {{count}} profile in Cookie Bot?",
"confirmBulkTitle_other": "Enrol {{count}} profiles in Cookie Bot?",
"confirmBulkDescription_one": "It books a nightly run against your shared remote hours. You choose the time and the sites next.",
"confirmBulkDescription_other": "They book {{count}} nightly runs against your shared remote hours. You choose the time and the sites next.",
"confirmBulkButton_one": "Continue with {{count}} profile",
"confirmBulkButton_other": "Continue with {{count}} profiles",
"sitesRequired": "Add at least one site.",
"addSitesFirst": "Add a site first",
"presetsMissing": "Depth presets unavailable"
},
"preset": {
"light": "Light",
"balanced": "Standard",
"deep": "Deep"
},
"preflight": {
"ineligible_one": "{{count}} profile can't run remotely",
"ineligible_other": "{{count}} profiles can't run remotely",
"reasonSync": "Sync is off",
"reasonEncrypted": "End-to-end encrypted",
"reasonNoFingerprint": "No recorded operating system",
"reasonCrossOs": "Built for {{os}} — no remote machine matches",
"reasonNoExitNode": "No proxy or VPN",
"fixSync": "Turn on sync",
"fixEncrypted": "Open sync settings",
"fixProxy": "Attach a proxy",
"fixFailed": "Could not apply that fix.",
"exitNodeHint": "Without a proxy or VPN the run leaves from the fleet's own datacenter address. Hours of traffic from a hosting network damages the profile's identity more than not warming it at all."
},
"conflict": {
"title": "{{email}} already warms this profile at {{time}}",
"detail": "Two schedules on one profile spend hours twice and can collide mid-run.",
"keepTheirs": "Use their schedule",
"replace": "Replace with mine",
"replaceForbidden": "Only a team owner or admin can change someone else's schedule.",
"askThem": "Copy {{email}}",
"emailCopied": "Email copied",
"copyFailed": "Could not copy the address."
},
"actionBar": {
"enrol": "Enrol in Cookie Bot",
"proRequired": "Cookie Bot requires a Pro or Team plan",
"noneEligible": "None of the selected profiles can be warmed remotely"
},
"actions": {
"enrol": "Enrol in Cookie Bot",
"editSchedule": "Edit schedule",
"runNow": "Run now",
"runStarted": "Run started",
"viewActivity": "View activity",
"runNotStarted": "The run did not start: {{reason}}"
},
"state": {
"enrolled": "Enrolled",
"notEnrolled": "Not enrolled",
"paused": "Paused",
"summary": "{{cadence}} at {{time}}",
"rowMenu": "Cookie Bot options for {{name}}",
"blocked": "Cannot run: {{reason}}"
},
"team": {
"title": "Team usage",
"pooled_one": "{{used}} of {{total}} h, {{count}} seat",
"pooled_other": "{{used}} of {{total}} h pooled across {{count}} seats",
"hours": "{{hours}} h",
"legendBot": "Cookie Bot",
"legendInteractive": "Interactive",
"columnMember": "Member",
"columnRuns": "Runs",
"columnHours": "Hours",
"columnShare": "Share",
"noActivity": "No remote sessions in this period.",
"soloNote": "Only your own usage so far. Invite teammates to see the pool split by member.",
"runsFailed": "· {{n}} failed"
},
"closeReason": {
"stoppedByUser": "Stopped by hand",
"maxDuration": "Reached the time limit",
"other": "Ended: {{reason}}"
},
"outcome": {
"notEntitled": "Not included in your plan",
"syncDisabled": "Cloud sync is off for this profile",
"encryptedSync": "End-to-end encrypted sync is not supported",
"proxyRequired": "No proxy or VPN attached",
"touchFingerprint": "Touch fingerprints are not supported",
"platformUnsupported": "No machine runs this profile's system",
"noSites": "No sites to visit",
"quotaExhausted": "Remote hours used up",
"profileLocked": "The profile was open somewhere else",
"noCapacity": "No machine was free",
"managerError": "The fleet could not start the browser",
"budgetExceeded": "The night's budget ran out",
"cancelledByUser": "Stopped by hand",
"unknown": "Unknown reason ({{code}})"
}
}
}
+351 -7
View File
@@ -258,7 +258,8 @@
"emptyCreate": "Crear perfil",
"emptyImport": "Importar perfiles",
"emptyFilteredTitle": "No se encontraron perfiles",
"emptyFilteredHint": "Ningún perfil coincide con este grupo o búsqueda. Prueba otro filtro o crea uno nuevo."
"emptyFilteredHint": "Ningún perfil coincide con este grupo o búsqueda. Prueba otro filtro o crea uno nuevo.",
"bot": "Bot"
},
"actions": {
"launch": "Iniciar",
@@ -421,6 +422,7 @@
"deleteProxy": "Eliminar proxy",
"cannotDelete_one": "No se puede eliminar: en uso por {{count}} perfil",
"cannotDelete_other": "No se puede eliminar: en uso por {{count}} perfiles",
"cannotDelete_many": "No se puede eliminar: en uso por {{count}} perfiles",
"syncEnabled": "Sincronización activada",
"syncDisabled": "Sincronización desactivada",
"updateSyncFailed": "Error al actualizar la sincronización",
@@ -814,6 +816,7 @@
"selectedCount_plural": "{{count}} cookies seleccionadas",
"dialogDescription_one": "Copiar cookies de un perfil de origen a {{count}} perfil seleccionado.",
"dialogDescription_other": "Copiar cookies de un perfil de origen a {{count}} perfiles seleccionados.",
"dialogDescription_many": "Copiar cookies de un perfil de origen a {{count}} perfiles seleccionados.",
"sourceProfile": "Perfil de origen",
"sourcePlaceholder": "Selecciona un perfil del que copiar cookies",
"running": "(en ejecución)",
@@ -832,6 +835,7 @@
"failedMessage": "Error al copiar las cookies: {{error}}",
"copyButton_one": "Copiar {{count}} cookie",
"copyButton_other": "Copiar {{count}} cookies",
"copyButton_many": "Copiar {{count}} cookies",
"copyButtonEmpty": "Copiar cookies"
},
"success": "Cookies copiadas exitosamente",
@@ -1403,6 +1407,7 @@
"deleteVpn": "Eliminar VPN",
"cannotDelete_one": "No se puede eliminar: en uso por {{count}} perfil",
"cannotDelete_other": "No se puede eliminar: en uso por {{count}} perfiles",
"cannotDelete_many": "No se puede eliminar: en uso por {{count}} perfiles",
"syncCannotDisable": "No se puede desactivar la sincronización mientras esta VPN esté en uso por perfiles sincronizados",
"deleteSuccess": "VPN eliminada correctamente",
"deleteFailed": "Error al eliminar la VPN",
@@ -1481,6 +1486,7 @@
"loading": "Cargando grupos...",
"profileCount_one": "{{count}} perfil",
"profileCount_other": "{{count}} perfiles",
"profileCount_many": "{{count}} perfiles",
"groupsLabel": "Grupos",
"profilesCol": "Perfiles",
"syncCannotDisable": "No se puede desactivar la sincronización mientras este grupo esté en uso por perfiles sincronizados",
@@ -1498,6 +1504,7 @@
"title": "Asignar proxy / VPN",
"description_one": "Asigna un proxy o VPN a {{count}} perfil seleccionado.",
"description_other": "Asigna un proxy o VPN a {{count}} perfiles seleccionados.",
"description_many": "Asigna un proxy o VPN a {{count}} perfiles seleccionados.",
"selectLabel": "Proxy / VPN",
"placeholder": "Selecciona un proxy o VPN",
"noProxy": "Sin proxy / VPN",
@@ -1517,6 +1524,7 @@
"title": "Asignar grupo",
"description_one": "Asigna un grupo a {{count}} perfil seleccionado.",
"description_other": "Asigna un grupo a {{count}} perfiles seleccionados.",
"description_many": "Asigna un grupo a {{count}} perfiles seleccionados.",
"selectLabel": "Grupo",
"placeholder": "Selecciona un grupo",
"noGroup": "Sin grupo (Predeterminado)",
@@ -1851,7 +1859,35 @@
"vlessConfigInvalid": "El URI de VLESS no es válido.",
"xrayUnavailable": "Xray-core no está disponible en este sistema.",
"xrayUnsupportedOs": "VLESS requiere macOS 12 o posterior.",
"xrayStartFailed": "No se pudo iniciar Xray-core."
"xrayStartFailed": "No se pudo iniciar Xray-core.",
"cloudNotSignedIn": "Inicia sesión en tu cuenta de Donut Browser para usar esto.",
"cloudUnreachable": "No se pudo contactar con los servidores de Donut Browser. Comprueba tu conexión e inténtalo de nuevo.",
"cloudRequestFailed": "La solicitud falló. Inténtalo de nuevo en un momento.",
"remoteRateLimited": "Demasiadas solicitudes. Espera un momento e inténtalo de nuevo.",
"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.",
"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.",
"remoteSyncInProgress": "Este perfil todavía se está subiendo a la sincronización en la nube. Espera a que termine e inténtalo de nuevo.",
"remoteHoursExhausted": "Has usado las {{used}} de tus {{granted}} horas remotas de este mes.",
"notTeamMember": "No perteneces a ningún equipo.",
"cookieBotNotEntitled": "Tu plan no incluye Cookie Bot.",
"cookieBotNotEnrolled": "Este perfil aún no está configurado para Cookie Bot.",
"cookieBotScheduleConflict": "{{email}} ya calienta este perfil a las {{time}}.",
"cookieBotRunInProgress": "Ya hay una ejecución en curso para este perfil.",
"cookieBotRunNotFound": "Esa ejecución ya no existe.",
"cookieBotInvalidSchedule": "Esa programación no es válida. Revisa la hora, los días y la duración.",
"cookieBotInvalidTimezone": "{{timezone}} no es una zona horaria que el servidor reconozca.",
"cookieBotInvalidPeriod": "Ese periodo no es válido. Usa un mes como 2026-08.",
"cookieBotSiteLimit": "Introduce entre {{min}} y {{max}} sitios, cada uno con una dirección http o https completa.",
"cookieBotRequiresCloudSync": "Activa primero la sincronización en la nube para este perfil: una máquina remota no tiene otra forma de obtenerlo.",
"cookieBotEncryptedSyncUnsupported": "Este perfil usa sincronización cifrada de extremo a extremo, que una máquina remota no puede descifrar. Cámbialo a sincronización normal.",
"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.",
"unknownCode": "Algo salió mal: {{code}}",
"cookieBotTouchFingerprintUnsupported": "Este perfil declara un dispositivo táctil, que el bot no puede controlar. Usa una huella de escritorio."
},
"rail": {
"profiles": "Perfiles",
@@ -1870,7 +1906,9 @@
},
"network": "Red",
"integrations": "Integraciones",
"account": "Cuenta"
"account": "Cuenta",
"cookieBot": "Cookie Bot",
"cookieBotRunning": "Cookie Bot: en ejecución ahora"
},
"pageTitle": {
"proxies": "Red",
@@ -1881,7 +1919,8 @@
"integrations": "Integraciones",
"account": "Cuenta",
"import": "Importar perfil",
"shortcuts": "Atajos de teclado"
"shortcuts": "Atajos de teclado",
"cookieBot": "Cookie Bot"
},
"encryption": {
"required": {
@@ -1924,7 +1963,8 @@
},
"tabs": {
"account": "Cuenta",
"selfHosted": "Autoalojado"
"selfHosted": "Autoalojado",
"teamUsage": "Uso del equipo"
},
"selfHosted": {
"title": "Servidor de sincronización autoalojado",
@@ -1937,7 +1977,8 @@
},
"deviceOrdinal": "{{ordinal}} de {{count}}",
"automationPrimaryOnly": "La automatización del navegador solo funciona en tu dispositivo principal (Dispositivo 1). Cierra sesión allí para usarla aquí.",
"automationActiveHere": "La automatización del navegador está activa en este dispositivo."
"automationActiveHere": "La automatización del navegador está activa en este dispositivo.",
"viewTeamUsage": "Ver el uso del equipo"
},
"shortcutsPage": {
"title": "Atajos de teclado",
@@ -1970,7 +2011,8 @@
"goGroups": "Ir a Grupos",
"goIntegrations": "Ir a Integraciones",
"goAccount": "Ir a Cuenta",
"goSettings": "Ir a Configuración"
"goSettings": "Ir a Configuración",
"goCookieBot": "Cookie Bot"
},
"closeConfirm": {
"title": "¿Cerrar Donut Browser?",
@@ -2102,5 +2144,307 @@
"matchToProxy": "Ajustar huella al proxy",
"matching": "Ajustando…",
"matchSuccess": "Huella actualizada para coincidir con el proxy. Reinicia el perfil para aplicar."
},
"cookieBot": {
"title": "Cookie Bot",
"description": "Calentamiento nocturno de perfiles en una máquina remota.",
"tabs": {
"overview": "Resumen",
"schedule": "Programación",
"activity": "Actividad",
"team": "Equipo"
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot calienta tus perfiles por la noche en una máquina remota, así conservan sus cookies y su historial sin que tu ordenador esté encendido. Requiere un plan Pro o Team."
},
"empty": {
"title": "No hay perfiles inscritos",
"hint": "Elige un perfil y el bot lo calentará por la noche en una máquina remota. Tu ordenador puede estar apagado.",
"cta": "Inscribir un perfil"
},
"tonight": {
"label": "Esta noche",
"nextRun": "Próxima ejecución {{time}}",
"dueCount_one": "{{count}} perfil pendiente",
"dueCount_other": "{{count}} perfiles pendientes",
"dueCount_many": "{{count}} perfiles pendientes",
"nothingScheduled": "Nada programado"
},
"lastDay": {
"label": "Últimas 24 horas",
"none": "Aún no hay ejecuciones",
"ran_one": "{{count}} ejecutado",
"ran_other": "{{count}} ejecutados",
"ran_many": "{{count}} ejecutados",
"partial_one": "{{count}} parcial",
"partial_other": "{{count}} parciales",
"partial_many": "{{count}} parciales",
"failed_one": "{{count}} fallido",
"failed_other": "{{count}} fallidos",
"failed_many": "{{count}} fallidos"
},
"chart": {
"machineTime": "Tiempo de máquina por noche",
"minutes": "{{minutes}} min"
},
"hours": {
"label": "Horas remotas",
"remaining": "Quedan {{remaining}} h de {{total}}",
"remainingOf": "de {{total}} h",
"used": "{{used}} de {{total}} h usadas",
"resets": "Se restablece el {{date}}",
"exhausted": "No quedan horas remotas. Las programaciones siguen inscritas y se reanudan en el próximo ciclo.",
"exhaustedOn": "No quedan horas remotas. Las programaciones siguen inscritas y se reanudan el {{date}}.",
"estimate": "Unas {{hours}} h por semana · quedan {{remaining}} h en este ciclo",
"estimateOverBudget": "Necesita unas {{hours}} h por semana: solo quedan {{remaining}} h",
"estimateOnly": "Unas {{hours}} h por semana"
},
"enrolled": {
"columnProfile": "Perfil",
"columnCadence": "Frecuencia",
"columnTime": "Hora",
"columnNextRun": "Próxima ejecución",
"columnLastRun": "Última ejecución",
"enrolProfiles": "Inscribir perfiles",
"edit": "Editar programación",
"neverRun": "Nunca",
"pausedNoHours": "En pausa: sin horas",
"profileMissing": "Ese perfil no está en este ordenador."
},
"schedule": {
"empty": "Todavía no hay nada programado.",
"quietHours_one": "{{count}} hora libre",
"quietHours_other": "{{count}} horas libres",
"quietHours_many": "{{count}} horas libres",
"crowded_one": "{{count}} perfil empieza a la vez",
"crowded_other": "{{count}} perfiles empiezan a la vez",
"crowded_many": "{{count}} perfiles empiezan a la vez",
"unenrol": "Quitar de Cookie Bot",
"unenrolTitle": "¿Quitar {{name}} de Cookie Bot?",
"unenrolDescription": "Dejará de calentarse esta noche. Las ejecuciones anteriores se conservan en Actividad.",
"unenrolled": "Quitado de Cookie Bot"
},
"status": {
"provisioning": "Preparando una máquina",
"ready": "Cargando el perfil",
"warming": "Calentando",
"finished": "Finalizando",
"failed": "Con errores"
},
"runStatus": {
"pending": "En cola",
"running": "En ejecución",
"succeeded": "Completa",
"partial": "Parcial",
"failed": "Fallida",
"skipped": "Omitida",
"cancelled": "Detenida"
},
"running": {
"label": "En ejecución ahora",
"more": "+{{count}} más",
"stop": "Detener la ejecución",
"stopped": "Ejecución detenida"
},
"live": {
"idle": "Ahora mismo no se está ejecutando nada",
"streamOffline": "Las actualizaciones en vivo están sin conexión",
"streamOfflineDetail": "Las actualizaciones en vivo están sin conexión; reconectando. Las ejecuciones de abajo pueden estar desactualizadas.",
"unnamedSession": "Sesión remota",
"notStartedYet": "La máquina aún no ha informado de una hora de inicio.",
"sitesProgress": "{{visited}} de {{total}} sitios",
"sitesUnknown": "Sitios aún no informados",
"consentHandled_one": "{{count}} aviso de consentimiento gestionado",
"consentHandled_other": "{{count}} avisos de consentimiento gestionados",
"consentHandled_many": "{{count}} avisos de consentimiento gestionados",
"consentUnknown": "Avisos de consentimiento aún no informados",
"billed": "{{duration}} facturados",
"billedUnknown": "Tiempo facturado aún no informado",
"chunk": "Parte {{index}} de {{total}}"
},
"history": {
"title": "Ejecuciones",
"allProfiles": "Todos los perfiles",
"searchPlaceholder": "Buscar perfiles…",
"filterAll": "Todas las ejecuciones",
"filterComplete": "Completas",
"filterPartial": "Parciales",
"filterFailed": "Fallidas",
"columnStarted": "Inicio",
"columnProfile": "Perfil",
"columnDuration": "Duración",
"columnSites": "Sitios",
"columnStatus": "Estado",
"columnOperator": "Operador",
"empty": "Aún no hay ejecuciones.",
"noMatch": "Ninguna ejecución coincide con este filtro.",
"unknownProfile": "Perfil desconocido",
"outcome": "Motivo: {{reason}}",
"sitesVisited": "{{visited}}/{{total}}",
"sitesFailed_one": "No se pudo acceder a {{count}} sitio",
"sitesFailed_other": "No se pudo acceder a {{count}} sitios",
"sitesFailed_many": "No se pudo acceder a {{count}} sitios",
"consentHandled_one": "{{count}} aviso de consentimiento gestionado",
"consentHandled_other": "{{count}} avisos de consentimiento gestionados",
"consentHandled_many": "{{count}} avisos de consentimiento gestionados",
"sitesUnknown": "Sin datos"
},
"duration": {
"hm": "{{hours}} h {{minutes}} min",
"ms": "{{minutes}} min {{seconds}} s",
"s": "{{seconds}} s"
},
"picker": {
"title": "Inscribir perfiles",
"description": "Elige los perfiles que el bot debe calentar por la noche.",
"searchPlaceholder": "Buscar perfiles…",
"noProfiles": "Ningún perfil coincide.",
"alreadyEnrolled": "Inscrito",
"continue_one": "Continuar con {{count}}",
"continue_other": "Continuar con {{count}}",
"continue_many": "Continuar con {{count}}"
},
"enrol": {
"titleOne": "Inscribir {{name}}",
"titleCount_one": "Inscribir {{count}} perfil",
"titleCount_other": "Inscribir {{count}} perfiles",
"titleCount_many": "Inscribir {{count}} perfiles",
"editTitle": "Editar programación",
"description": "El bot abre el perfil en una máquina remota y navega por los sitios que indiques. Tu ordenador puede estar apagado.",
"summaryNightly": "Se ejecuta cada noche a las {{time}}, hasta {{minutes}} min cada vez.",
"summaryWeeknights": "Se ejecuta de lunes a viernes a las {{time}}, hasta {{minutes}} min cada vez.",
"summaryAlternate": "Se ejecuta una noche sí y otra no a las {{time}}, hasta {{minutes}} min cada vez.",
"summaryCustom_one": "Se ejecuta {{count}} noche a la semana a las {{time}}, hasta {{minutes}} min cada vez.",
"summaryCustom_other": "Se ejecuta {{count}} noches a la semana a las {{time}}, hasta {{minutes}} min cada vez.",
"summaryCustom_many": "Se ejecuta {{count}} noches a la semana a las {{time}}, hasta {{minutes}} min cada vez.",
"confirm": "Inscribir para esta noche",
"confirmSome": "Inscribir {{eligible}} de {{total}} esta noche",
"fixFirst": "Corrige esto primero",
"saving": "Inscribiendo…",
"saved": "Programación guardada",
"enrolled_one": "{{count}} perfil inscrito",
"enrolled_other": "{{count}} perfiles inscritos",
"enrolled_many": "{{count}} perfiles inscritos",
"adjust": "Ajustar la programación",
"cadenceLabel": "Frecuencia",
"cadenceNightly": "Cada noche",
"cadenceWeeknights": "Noches entre semana",
"cadenceAlternate": "Noches alternas",
"cadenceCustom_one": "{{count}} noche a la semana",
"cadenceCustom_other": "{{count}} noches a la semana",
"cadenceCustom_many": "{{count}} noches a la semana",
"timeLabel": "Hora de inicio",
"timeHint": "Local a la zona horaria de la huella de cada perfil.",
"maxMinutesLabel": "Minutos máximos",
"intensityLabel": "Profundidad",
"sitesLabel": "Sitios",
"sitesPlaceholder": "Una dirección por línea",
"sitesHint_one": "{{count}} sitio. Solo se visitan las páginas que indiques aquí.",
"sitesHint_other": "{{count}} sitios. Solo se visitan las páginas que indiques aquí.",
"sitesHint_many": "{{count}} sitios. Solo se visitan las páginas que indiques aquí.",
"sitesTooMany": "Como máximo {{max}} sitios.",
"presetsUnavailable": "No se pudieron cargar los ajustes de profundidad. Inténtalo de nuevo en un momento.",
"confirmBulkTitle_one": "¿Inscribir {{count}} perfil en Cookie Bot?",
"confirmBulkTitle_other": "¿Inscribir {{count}} perfiles en Cookie Bot?",
"confirmBulkTitle_many": "¿Inscribir {{count}} perfiles en Cookie Bot?",
"confirmBulkDescription_one": "Reserva una ejecución nocturna con cargo a vuestras horas remotas compartidas. La hora y los sitios se eligen a continuación.",
"confirmBulkDescription_other": "Reservan {{count}} ejecuciones nocturnas con cargo a vuestras horas remotas compartidas. La hora y los sitios se eligen a continuación.",
"confirmBulkDescription_many": "Reservan {{count}} ejecuciones nocturnas con cargo a vuestras horas remotas compartidas. La hora y los sitios se eligen a continuación.",
"confirmBulkButton_one": "Continuar con {{count}} perfil",
"confirmBulkButton_other": "Continuar con {{count}} perfiles",
"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"
},
"preset": {
"light": "Ligera",
"balanced": "Estándar",
"deep": "Profunda"
},
"preflight": {
"ineligible_one": "{{count}} perfil no puede ejecutarse en remoto",
"ineligible_other": "{{count}} perfiles no pueden ejecutarse en remoto",
"ineligible_many": "{{count}} perfiles no pueden ejecutarse en remoto",
"reasonSync": "La sincronización está desactivada",
"reasonEncrypted": "Cifrado de extremo a extremo",
"reasonNoFingerprint": "Sin sistema operativo registrado",
"reasonCrossOs": "Creado para {{os}}: ninguna máquina remota coincide",
"reasonNoExitNode": "Sin proxy ni VPN",
"fixSync": "Activar la sincronización",
"fixEncrypted": "Abrir los ajustes de sincronización",
"fixProxy": "Asignar un proxy",
"fixFailed": "No se pudo aplicar esa corrección.",
"exitNodeHint": "Sin proxy ni VPN, la ejecución sale por la propia dirección del centro de datos de la flota. Horas de tráfico desde una red de alojamiento dañan la identidad del perfil más que no calentarlo en absoluto."
},
"conflict": {
"title": "{{email}} ya calienta este perfil a las {{time}}",
"detail": "Dos programaciones sobre un mismo perfil gastan horas por duplicado y pueden chocar durante la ejecución.",
"keepTheirs": "Usar su programación",
"replace": "Sustituir por la mía",
"replaceForbidden": "Solo el propietario o un administrador del equipo puede cambiar la programación de otra persona.",
"askThem": "Copiar {{email}}",
"emailCopied": "Correo copiado",
"copyFailed": "No se pudo copiar la dirección."
},
"actionBar": {
"enrol": "Inscribir en Cookie Bot",
"proRequired": "Cookie Bot requiere un plan Pro o Team",
"noneEligible": "Ninguno de los perfiles seleccionados se puede calentar en remoto"
},
"actions": {
"enrol": "Inscribir en Cookie Bot",
"editSchedule": "Editar programación",
"runNow": "Ejecutar ahora",
"runStarted": "Ejecución iniciada",
"viewActivity": "Ver la actividad",
"runNotStarted": "La ejecución no comenzó: {{reason}}"
},
"state": {
"enrolled": "Inscrito",
"notEnrolled": "No inscrito",
"paused": "En pausa",
"summary": "{{cadence}} a las {{time}}",
"rowMenu": "Opciones de Cookie Bot para {{name}}",
"blocked": "No puede ejecutarse: {{reason}}"
},
"team": {
"title": "Uso del equipo",
"pooled_one": "{{used}} de {{total}} h, {{count}} plaza",
"pooled_other": "{{used}} de {{total}} h compartidas entre {{count}} plazas",
"pooled_many": "{{used}} de {{total}} h compartidas entre {{count}} plazas",
"hours": "{{hours}} h",
"legendBot": "Cookie Bot",
"legendInteractive": "Interactivo",
"columnMember": "Miembro",
"columnRuns": "Ejecuciones",
"columnHours": "Horas",
"columnShare": "Cuota",
"noActivity": "No hay sesiones remotas en este periodo.",
"soloNote": "De momento solo tu propio uso. Invita a compañeros para ver el reparto por miembro.",
"runsFailed": "· {{n}} con errores"
},
"closeReason": {
"stoppedByUser": "Detenido a mano",
"maxDuration": "Alcanzó el límite de tiempo",
"other": "Finalizó: {{reason}}"
},
"outcome": {
"notEntitled": "No está incluido en tu plan",
"syncDisabled": "La sincronización está desactivada en este perfil",
"encryptedSync": "La sincronización cifrada de extremo a extremo no es compatible",
"proxyRequired": "Sin proxy ni VPN asignado",
"touchFingerprint": "Las huellas táctiles no son compatibles",
"platformUnsupported": "Ninguna máquina ejecuta el sistema de este perfil",
"noSites": "No hay sitios que visitar",
"quotaExhausted": "Horas remotas agotadas",
"profileLocked": "El perfil estaba abierto en otro lugar",
"noCapacity": "No había ninguna máquina libre",
"managerError": "La flota no pudo iniciar el navegador",
"budgetExceeded": "Se agotó el tiempo previsto para la noche",
"cancelledByUser": "Detenido a mano",
"unknown": "Motivo desconocido ({{code}})"
}
}
}
+351 -7
View File
@@ -258,7 +258,8 @@
"emptyCreate": "Créer un profil",
"emptyImport": "Importer des profils",
"emptyFilteredTitle": "Aucun profil trouvé",
"emptyFilteredHint": "Aucun profil ne correspond à ce groupe ou à cette recherche. Essayez un autre filtre ou créez-en un."
"emptyFilteredHint": "Aucun profil ne correspond à ce groupe ou à cette recherche. Essayez un autre filtre ou créez-en un.",
"bot": "Bot"
},
"actions": {
"launch": "Lancer",
@@ -421,6 +422,7 @@
"deleteProxy": "Supprimer le proxy",
"cannotDelete_one": "Suppression impossible : utilisé par {{count}} profil",
"cannotDelete_other": "Suppression impossible : utilisé par {{count}} profils",
"cannotDelete_many": "Suppression impossible : utilisé par {{count}} profils",
"syncEnabled": "Sync activée",
"syncDisabled": "Sync désactivée",
"updateSyncFailed": "Échec de la mise à jour de la sync",
@@ -814,6 +816,7 @@
"selectedCount_plural": "{{count}} cookies sélectionnés",
"dialogDescription_one": "Copier les cookies d'un profil source vers {{count}} profil sélectionné.",
"dialogDescription_other": "Copier les cookies d'un profil source vers {{count}} profils sélectionnés.",
"dialogDescription_many": "Copier les cookies d'un profil source vers {{count}} profils sélectionnés.",
"sourceProfile": "Profil source",
"sourcePlaceholder": "Sélectionnez un profil pour copier les cookies",
"running": "(en cours)",
@@ -832,6 +835,7 @@
"failedMessage": "Échec de la copie des cookies : {{error}}",
"copyButton_one": "Copier {{count}} cookie",
"copyButton_other": "Copier {{count}} cookies",
"copyButton_many": "Copier {{count}} cookies",
"copyButtonEmpty": "Copier les cookies"
},
"success": "Cookies copiés avec succès",
@@ -1403,6 +1407,7 @@
"deleteVpn": "Supprimer le VPN",
"cannotDelete_one": "Suppression impossible : utilisé par {{count}} profil",
"cannotDelete_other": "Suppression impossible : utilisé par {{count}} profils",
"cannotDelete_many": "Suppression impossible : utilisé par {{count}} profils",
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce VPN est utilisé par des profils synchronisés",
"deleteSuccess": "VPN supprimé avec succès",
"deleteFailed": "Échec de la suppression du VPN",
@@ -1481,6 +1486,7 @@
"loading": "Chargement des groupes...",
"profileCount_one": "{{count}} profil",
"profileCount_other": "{{count}} profils",
"profileCount_many": "{{count}} profils",
"groupsLabel": "Groupes",
"profilesCol": "Profils",
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce groupe est utilisé par des profils synchronisés",
@@ -1498,6 +1504,7 @@
"title": "Assigner un proxy / VPN",
"description_one": "Assigner un proxy ou VPN à {{count}} profil sélectionné.",
"description_other": "Assigner un proxy ou VPN à {{count}} profils sélectionnés.",
"description_many": "Assigner un proxy ou VPN à {{count}} profils sélectionnés.",
"selectLabel": "Proxy / VPN",
"placeholder": "Sélectionnez un proxy ou VPN",
"noProxy": "Aucun proxy / VPN",
@@ -1517,6 +1524,7 @@
"title": "Assigner un groupe",
"description_one": "Assigner un groupe à {{count}} profil sélectionné.",
"description_other": "Assigner un groupe à {{count}} profils sélectionnés.",
"description_many": "Assigner un groupe à {{count}} profils sélectionnés.",
"selectLabel": "Groupe",
"placeholder": "Sélectionnez un groupe",
"noGroup": "Aucun groupe (par défaut)",
@@ -1851,7 +1859,35 @@
"vlessConfigInvalid": "LURI VLESS nest pas valide.",
"xrayUnavailable": "Xray-core nest pas disponible sur ce système.",
"xrayUnsupportedOs": "VLESS nécessite macOS 12 ou une version ultérieure.",
"xrayStartFailed": "Impossible de démarrer Xray-core."
"xrayStartFailed": "Impossible de démarrer Xray-core.",
"cloudNotSignedIn": "Connectez-vous à votre compte Donut Browser pour utiliser cette fonction.",
"cloudUnreachable": "Impossible de joindre les serveurs de Donut Browser. Vérifiez votre connexion et réessayez.",
"cloudRequestFailed": "La requête a échoué. Réessayez dans un instant.",
"remoteRateLimited": "Trop de requêtes. Patientez un instant et réessayez.",
"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.",
"remoteSessionRefused": "La machine distante a refusé cette session.",
"remoteSessionNotFound": "Cette session distante n'existe plus.",
"remoteSessionConflict": "Ce profil est déjà ouvert ailleurs.",
"remoteSyncInProgress": "Ce profil est encore en cours d'envoi vers la synchronisation cloud. Attendez la fin, puis réessayez.",
"remoteHoursExhausted": "Vous avez utilisé les {{used}} de vos {{granted}} heures distantes de ce mois.",
"notTeamMember": "Vous n'appartenez à aucune équipe.",
"cookieBotNotEntitled": "Votre forfait n'inclut pas Cookie Bot.",
"cookieBotNotEnrolled": "Ce profil n'est pas encore configuré pour Cookie Bot.",
"cookieBotScheduleConflict": "{{email}} chauffe déjà ce profil à {{time}}.",
"cookieBotRunInProgress": "Une exécution est déjà en cours pour ce profil.",
"cookieBotRunNotFound": "Cette exécution n'existe plus.",
"cookieBotInvalidSchedule": "Cette planification n'est pas valide. Vérifiez l'heure, les jours et la durée.",
"cookieBotInvalidTimezone": "{{timezone}} n'est pas un fuseau horaire reconnu par le serveur.",
"cookieBotInvalidPeriod": "Cette période n'est pas valide. Utilisez un mois tel que 2026-08.",
"cookieBotSiteLimit": "Saisissez entre {{min}} et {{max}} sites, chacun sous forme d'adresse http ou https complète.",
"cookieBotRequiresCloudSync": "Activez d'abord la synchronisation cloud pour ce profil : une machine distante n'a aucun autre moyen de l'obtenir.",
"cookieBotEncryptedSyncUnsupported": "Ce profil utilise une synchronisation chiffrée de bout en bout, qu'une machine distante ne peut pas déchiffrer. Passez-le en synchronisation normale.",
"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.",
"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."
},
"rail": {
"profiles": "Profils",
@@ -1870,7 +1906,9 @@
},
"network": "Réseau",
"integrations": "Intégrations",
"account": "Compte"
"account": "Compte",
"cookieBot": "Cookie Bot",
"cookieBotRunning": "Cookie Bot : en cours d'exécution"
},
"pageTitle": {
"proxies": "Réseau",
@@ -1881,7 +1919,8 @@
"integrations": "Intégrations",
"account": "Compte",
"import": "Importer un profil",
"shortcuts": "Raccourcis clavier"
"shortcuts": "Raccourcis clavier",
"cookieBot": "Cookie Bot"
},
"encryption": {
"required": {
@@ -1924,7 +1963,8 @@
},
"tabs": {
"account": "Compte",
"selfHosted": "Auto-hébergé"
"selfHosted": "Auto-hébergé",
"teamUsage": "Utilisation de l'équipe"
},
"selfHosted": {
"title": "Serveur de synchronisation auto-hébergé",
@@ -1937,7 +1977,8 @@
},
"deviceOrdinal": "{{ordinal}} sur {{count}}",
"automationPrimaryOnly": "L'automatisation du navigateur ne fonctionne que sur votre appareil principal (Appareil 1). Déconnectez-vous là-bas pour l'utiliser ici.",
"automationActiveHere": "L'automatisation du navigateur est active sur cet appareil."
"automationActiveHere": "L'automatisation du navigateur est active sur cet appareil.",
"viewTeamUsage": "Voir l'utilisation de l'équipe"
},
"shortcutsPage": {
"title": "Raccourcis clavier",
@@ -1970,7 +2011,8 @@
"goGroups": "Aller à Groupes",
"goIntegrations": "Aller à Intégrations",
"goAccount": "Aller à Compte",
"goSettings": "Aller à Paramètres"
"goSettings": "Aller à Paramètres",
"goCookieBot": "Cookie Bot"
},
"closeConfirm": {
"title": "Fermer Donut Browser ?",
@@ -2102,5 +2144,307 @@
"matchToProxy": "Aligner l'empreinte sur le proxy",
"matching": "Alignement…",
"matchSuccess": "Empreinte mise à jour pour correspondre au proxy. Relancez le profil pour l'appliquer."
},
"cookieBot": {
"title": "Cookie Bot",
"description": "Chauffe des profils la nuit sur une machine distante.",
"tabs": {
"overview": "Aperçu",
"schedule": "Planification",
"activity": "Activité",
"team": "Équipe"
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot chauffe vos profils la nuit sur une machine distante : ils conservent leurs cookies et leur historique sans que votre ordinateur soit allumé. Nécessite un forfait Pro ou Team."
},
"empty": {
"title": "Aucun profil inscrit",
"hint": "Choisissez un profil et le bot le chauffe la nuit sur une machine distante. Votre ordinateur peut rester éteint.",
"cta": "Inscrire un profil"
},
"tonight": {
"label": "Cette nuit",
"nextRun": "Prochaine exécution {{time}}",
"dueCount_one": "{{count}} profil prévu",
"dueCount_other": "{{count}} profils prévus",
"dueCount_many": "{{count}} profils prévus",
"nothingScheduled": "Rien de planifié"
},
"lastDay": {
"label": "Dernières 24 heures",
"none": "Aucune exécution pour l'instant",
"ran_one": "{{count}} exécutée",
"ran_other": "{{count}} exécutées",
"ran_many": "{{count}} exécutées",
"partial_one": "{{count}} partielle",
"partial_other": "{{count}} partielles",
"partial_many": "{{count}} partielles",
"failed_one": "{{count}} en échec",
"failed_other": "{{count}} en échec",
"failed_many": "{{count}} en échec"
},
"chart": {
"machineTime": "Temps machine par nuit",
"minutes": "{{minutes}} min"
},
"hours": {
"label": "Heures distantes",
"remaining": "{{remaining}} h restantes sur {{total}}",
"remainingOf": "sur {{total}} h",
"used": "{{used}} h utilisées sur {{total}}",
"resets": "Réinitialisation le {{date}}",
"exhausted": "Plus d'heures distantes. Les planifications restent inscrites et reprennent au prochain cycle.",
"exhaustedOn": "Plus d'heures distantes. Les planifications restent inscrites et reprennent le {{date}}.",
"estimate": "Environ {{hours}} h par semaine · {{remaining}} h restantes sur ce cycle",
"estimateOverBudget": "Demande environ {{hours}} h par semaine : il ne reste que {{remaining}} h",
"estimateOnly": "Environ {{hours}} h par semaine"
},
"enrolled": {
"columnProfile": "Profil",
"columnCadence": "Fréquence",
"columnTime": "Heure",
"columnNextRun": "Prochaine exécution",
"columnLastRun": "Dernière exécution",
"enrolProfiles": "Inscrire des profils",
"edit": "Modifier la planification",
"neverRun": "Jamais",
"pausedNoHours": "En pause : plus d'heures",
"profileMissing": "Ce profil n'est pas sur cet ordinateur."
},
"schedule": {
"empty": "Rien n'est encore planifié.",
"quietHours_one": "{{count}} heure libre",
"quietHours_other": "{{count}} heures libres",
"quietHours_many": "{{count}} heures libres",
"crowded_one": "{{count}} profil démarre en même temps",
"crowded_other": "{{count}} profils démarrent en même temps",
"crowded_many": "{{count}} profils démarrent en même temps",
"unenrol": "Retirer de Cookie Bot",
"unenrolTitle": "Retirer {{name}} de Cookie Bot ?",
"unenrolDescription": "La chauffe s'arrête dès cette nuit. Les exécutions passées restent dans Activité.",
"unenrolled": "Retiré de Cookie Bot"
},
"status": {
"provisioning": "Préparation d'une machine",
"ready": "Chargement du profil",
"warming": "Chauffe en cours",
"finished": "Finalisation",
"failed": "En échec"
},
"runStatus": {
"pending": "En file d'attente",
"running": "En cours",
"succeeded": "Terminée",
"partial": "Partielle",
"failed": "En échec",
"skipped": "Ignorée",
"cancelled": "Arrêtée"
},
"running": {
"label": "En cours d'exécution",
"more": "+{{count}} de plus",
"stop": "Arrêter l'exécution",
"stopped": "Exécution arrêtée"
},
"live": {
"idle": "Rien ne s'exécute pour le moment",
"streamOffline": "Les mises à jour en direct sont hors ligne",
"streamOfflineDetail": "Les mises à jour en direct sont hors ligne, reconnexion en cours. Les exécutions ci-dessous peuvent être obsolètes.",
"unnamedSession": "Session distante",
"notStartedYet": "La machine n'a pas encore signalé d'heure de début.",
"sitesProgress": "{{visited}} sites sur {{total}}",
"sitesUnknown": "Sites pas encore signalés",
"consentHandled_one": "{{count}} bandeau de consentement traité",
"consentHandled_other": "{{count}} bandeaux de consentement traités",
"consentHandled_many": "{{count}} bandeaux de consentement traités",
"consentUnknown": "Bandeaux de consentement pas encore signalés",
"billed": "{{duration}} facturées",
"billedUnknown": "Temps facturé pas encore signalé",
"chunk": "Partie {{index}} sur {{total}}"
},
"history": {
"title": "Exécutions",
"allProfiles": "Tous les profils",
"searchPlaceholder": "Rechercher des profils…",
"filterAll": "Toutes les exécutions",
"filterComplete": "Terminées",
"filterPartial": "Partielles",
"filterFailed": "En échec",
"columnStarted": "Début",
"columnProfile": "Profil",
"columnDuration": "Durée",
"columnSites": "Sites",
"columnStatus": "Statut",
"columnOperator": "Opérateur",
"empty": "Aucune exécution pour l'instant.",
"noMatch": "Aucune exécution ne correspond à ce filtre.",
"unknownProfile": "Profil inconnu",
"outcome": "Raison : {{reason}}",
"sitesVisited": "{{visited}}/{{total}}",
"sitesFailed_one": "{{count}} site injoignable",
"sitesFailed_other": "{{count}} sites injoignables",
"sitesFailed_many": "{{count}} sites injoignables",
"consentHandled_one": "{{count}} bandeau de consentement traité",
"consentHandled_other": "{{count}} bandeaux de consentement traités",
"consentHandled_many": "{{count}} bandeaux de consentement traités",
"sitesUnknown": "Non communiqué"
},
"duration": {
"hm": "{{hours}} h {{minutes}} min",
"ms": "{{minutes}} min {{seconds}} s",
"s": "{{seconds}} s"
},
"picker": {
"title": "Inscrire des profils",
"description": "Choisissez les profils que le bot doit chauffer la nuit.",
"searchPlaceholder": "Rechercher des profils…",
"noProfiles": "Aucun profil ne correspond.",
"alreadyEnrolled": "Inscrit",
"continue_one": "Continuer avec {{count}}",
"continue_other": "Continuer avec {{count}}",
"continue_many": "Continuer avec {{count}}"
},
"enrol": {
"titleOne": "Inscrire {{name}}",
"titleCount_one": "Inscrire {{count}} profil",
"titleCount_other": "Inscrire {{count}} profils",
"titleCount_many": "Inscrire {{count}} profils",
"editTitle": "Modifier la planification",
"description": "Le bot ouvre le profil sur une machine distante et parcourt les sites que vous indiquez. Votre ordinateur peut rester éteint.",
"summaryNightly": "S'exécute chaque nuit à {{time}}, jusqu'à {{minutes}} min à chaque fois.",
"summaryWeeknights": "S'exécute du lundi au vendredi à {{time}}, jusqu'à {{minutes}} min à chaque fois.",
"summaryAlternate": "S'exécute une nuit sur deux à {{time}}, jusqu'à {{minutes}} min à chaque fois.",
"summaryCustom_one": "S'exécute {{count}} nuit par semaine à {{time}}, jusqu'à {{minutes}} min à chaque fois.",
"summaryCustom_other": "S'exécute {{count}} nuits par semaine à {{time}}, jusqu'à {{minutes}} min à chaque fois.",
"summaryCustom_many": "S'exécute {{count}} nuits par semaine à {{time}}, jusqu'à {{minutes}} min à chaque fois.",
"confirm": "Inscrire pour cette nuit",
"confirmSome": "Inscrire {{eligible}} profils sur {{total}} cette nuit",
"fixFirst": "Corrigez d'abord ceci",
"saving": "Inscription…",
"saved": "Planification enregistrée",
"enrolled_one": "{{count}} profil inscrit",
"enrolled_other": "{{count}} profils inscrits",
"enrolled_many": "{{count}} profils inscrits",
"adjust": "Ajuster la planification",
"cadenceLabel": "Fréquence",
"cadenceNightly": "Chaque nuit",
"cadenceWeeknights": "Nuits en semaine",
"cadenceAlternate": "Une nuit sur deux",
"cadenceCustom_one": "{{count}} nuit par semaine",
"cadenceCustom_other": "{{count}} nuits par semaine",
"cadenceCustom_many": "{{count}} nuits par semaine",
"timeLabel": "Heure de début",
"timeHint": "Selon le fuseau horaire de l'empreinte de chaque profil.",
"maxMinutesLabel": "Minutes maximum",
"intensityLabel": "Profondeur",
"sitesLabel": "Sites",
"sitesPlaceholder": "Une adresse par ligne",
"sitesHint_one": "{{count}} site. Seules les pages indiquées ici sont visitées.",
"sitesHint_other": "{{count}} sites. Seules les pages indiquées ici sont visitées.",
"sitesHint_many": "{{count}} sites. Seules les pages indiquées ici sont visitées.",
"sitesTooMany": "{{max}} sites au maximum.",
"presetsUnavailable": "Impossible de charger les préréglages de profondeur. Réessayez dans un instant.",
"confirmBulkTitle_one": "Inscrire {{count}} profil à Cookie Bot ?",
"confirmBulkTitle_other": "Inscrire {{count}} profils à Cookie Bot ?",
"confirmBulkTitle_many": "Inscrire {{count}} profils à Cookie Bot ?",
"confirmBulkDescription_one": "Cela réserve une exécution nocturne sur vos heures distantes partagées. Vous choisissez l'heure et les sites juste après.",
"confirmBulkDescription_other": "Cela réserve {{count}} exécutions nocturnes sur vos heures distantes partagées. Vous choisissez l'heure et les sites juste après.",
"confirmBulkDescription_many": "Cela réserve {{count}} exécutions nocturnes sur vos heures distantes partagées. Vous choisissez l'heure et les sites juste après.",
"confirmBulkButton_one": "Continuer avec {{count}} profil",
"confirmBulkButton_other": "Continuer avec {{count}} profils",
"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"
},
"preset": {
"light": "Légère",
"balanced": "Standard",
"deep": "Approfondie"
},
"preflight": {
"ineligible_one": "{{count}} profil ne peut pas s'exécuter à distance",
"ineligible_other": "{{count}} profils ne peuvent pas s'exécuter à distance",
"ineligible_many": "{{count}} profils ne peuvent pas s'exécuter à distance",
"reasonSync": "La synchronisation est désactivée",
"reasonEncrypted": "Chiffré de bout en bout",
"reasonNoFingerprint": "Aucun système d'exploitation enregistré",
"reasonCrossOs": "Conçu pour {{os}} : aucune machine distante ne correspond",
"reasonNoExitNode": "Ni proxy ni VPN",
"fixSync": "Activer la synchronisation",
"fixEncrypted": "Ouvrir les réglages de synchronisation",
"fixProxy": "Associer un proxy",
"fixFailed": "Impossible d'appliquer ce correctif.",
"exitNodeHint": "Sans proxy ni VPN, l'exécution sort par l'adresse du centre de données de la flotte. Des heures de trafic depuis un réseau d'hébergement abîment l'identité du profil plus que de ne pas le chauffer du tout."
},
"conflict": {
"title": "{{email}} chauffe déjà ce profil à {{time}}",
"detail": "Deux planifications sur un même profil consomment les heures en double et peuvent entrer en collision en pleine exécution.",
"keepTheirs": "Garder sa planification",
"replace": "Remplacer par la mienne",
"replaceForbidden": "Seul le propriétaire ou un administrateur de l'équipe peut modifier la planification de quelqu'un d'autre.",
"askThem": "Copier {{email}}",
"emailCopied": "Adresse copiée",
"copyFailed": "Impossible de copier l'adresse."
},
"actionBar": {
"enrol": "Inscrire à Cookie Bot",
"proRequired": "Cookie Bot nécessite un forfait Pro ou Team",
"noneEligible": "Aucun des profils sélectionnés ne peut être chauffé à distance"
},
"actions": {
"enrol": "Inscrire à Cookie Bot",
"editSchedule": "Modifier la planification",
"runNow": "Exécuter maintenant",
"runStarted": "Exécution lancée",
"viewActivity": "Voir l'activité",
"runNotStarted": "L'exécution n'a pas démarré : {{reason}}"
},
"state": {
"enrolled": "Inscrit",
"notEnrolled": "Non inscrit",
"paused": "En pause",
"summary": "{{cadence}} à {{time}}",
"rowMenu": "Options Cookie Bot pour {{name}}",
"blocked": "Ne peut pas s'exécuter : {{reason}}"
},
"team": {
"title": "Utilisation de l'équipe",
"pooled_one": "{{used}} h sur {{total}}, {{count}} siège",
"pooled_other": "{{used}} h sur {{total}} mutualisées entre {{count}} sièges",
"pooled_many": "{{used}} h sur {{total}} mutualisées entre {{count}} sièges",
"hours": "{{hours}} h",
"legendBot": "Cookie Bot",
"legendInteractive": "Interactif",
"columnMember": "Membre",
"columnRuns": "Exécutions",
"columnHours": "Heures",
"columnShare": "Part",
"noActivity": "Aucune session distante sur cette période.",
"soloNote": "Pour l'instant, seulement votre propre utilisation. Invitez des coéquipiers pour voir la répartition par membre.",
"runsFailed": "· {{n}} en échec"
},
"closeReason": {
"stoppedByUser": "Arrêté à la main",
"maxDuration": "Limite de durée atteinte",
"other": "Terminé : {{reason}}"
},
"outcome": {
"notEntitled": "Non inclus dans votre offre",
"syncDisabled": "La synchronisation est désactivée pour ce profil",
"encryptedSync": "La synchronisation chiffrée de bout en bout n'est pas prise en charge",
"proxyRequired": "Aucun proxy ni VPN attaché",
"touchFingerprint": "Les empreintes tactiles ne sont pas prises en charge",
"platformUnsupported": "Aucune machine n'exécute le système de ce profil",
"noSites": "Aucun site à visiter",
"quotaExhausted": "Heures distantes épuisées",
"profileLocked": "Le profil était ouvert ailleurs",
"noCapacity": "Aucune machine n'était libre",
"managerError": "La flotte n'a pas pu démarrer le navigateur",
"budgetExceeded": "Le temps prévu pour la nuit est épuisé",
"cancelledByUser": "Arrêté à la main",
"unknown": "Raison inconnue ({{code}})"
}
}
}
+324 -7
View File
@@ -258,7 +258,8 @@
"emptyCreate": "プロファイルを作成",
"emptyImport": "プロファイルをインポート",
"emptyFilteredTitle": "プロファイルが見つかりません",
"emptyFilteredHint": "このグループまたは検索に一致するプロファイルはありません。別のフィルターを試すか、新規作成してください。"
"emptyFilteredHint": "このグループまたは検索に一致するプロファイルはありません。別のフィルターを試すか、新規作成してください。",
"bot": "ボット"
},
"actions": {
"launch": "起動",
@@ -1851,7 +1852,35 @@
"vlessConfigInvalid": "VLESS URI が無効です。",
"xrayUnavailable": "このシステムでは Xray-core を利用できません。",
"xrayUnsupportedOs": "VLESS には macOS 12 以降が必要です。",
"xrayStartFailed": "Xray-core を起動できませんでした。"
"xrayStartFailed": "Xray-core を起動できませんでした。",
"cloudNotSignedIn": "この機能を使うには Donut Browser アカウントにサインインしてください。",
"cloudUnreachable": "Donut Browser のサーバーに接続できませんでした。接続を確認してもう一度お試しください。",
"cloudRequestFailed": "リクエストに失敗しました。しばらくしてからもう一度お試しください。",
"remoteRateLimited": "リクエストが多すぎます。少し待ってからもう一度お試しください。",
"remoteNoCapacity": "現在空いているリモートマシンがありません。数分後にもう一度お試しください。",
"remoteNotEntitled": "ご利用のプランにはリモート実行が含まれていません。",
"remoteSessionRefused": "リモートマシンがこのセッションを拒否しました。",
"remoteSessionNotFound": "そのリモートセッションはすでに存在しません。",
"remoteSessionConflict": "このプロファイルはすでに別の場所で開かれています。",
"remoteSyncInProgress": "このプロファイルはまだクラウド同期にアップロード中です。同期の完了を待ってからお試しください。",
"remoteHoursExhausted": "今月のリモート時間 {{granted}} のうち {{used}} をすべて使い切りました。",
"notTeamMember": "どのチームにも所属していません。",
"cookieBotNotEntitled": "ご利用のプランには Cookie Bot が含まれていません。",
"cookieBotNotEnrolled": "このプロファイルはまだ Cookie Bot 用に設定されていません。",
"cookieBotScheduleConflict": "{{email}} がすでにこのプロファイルを {{time}} にウォームアップしています。",
"cookieBotRunInProgress": "このプロファイルではすでに実行が進行中です。",
"cookieBotRunNotFound": "その実行はすでに存在しません。",
"cookieBotInvalidSchedule": "そのスケジュールは無効です。実行時刻、曜日、時間の長さを確認してください。",
"cookieBotInvalidTimezone": "{{timezone}} はサーバーが認識できないタイムゾーンです。",
"cookieBotInvalidPeriod": "その期間は無効です。2026-08 のような月を指定してください。",
"cookieBotSiteLimit": "サイトは {{min}} 件以上 {{max}} 件以下で、それぞれ完全な http または https のアドレスにしてください。",
"cookieBotRequiresCloudSync": "先にこのプロファイルのクラウド同期を有効にしてください。リモートマシンが取得する手段が他にありません。",
"cookieBotEncryptedSyncUnsupported": "このプロファイルはエンドツーエンド暗号化された同期を使用しており、リモートマシンでは復号できません。通常の同期に切り替えてください。",
"cookieBotUnknownPlatform": "このプロファイルには OS が記録されていないため、マシンを割り当てられません。",
"cookieBotUnsupportedPlatform": "Cookie Bot は {{platform}} のプロファイルを実行できません。対応しているのは Windows と macOS のプロファイルのみです。",
"cookieBotRequiresExitNode": "先にプロキシまたは VPN を設定してください。設定しないと通信がデータセンターのアドレスから出て、プロファイルの信頼性を損ないます。",
"unknownCode": "エラーが発生しました: {{code}}",
"cookieBotTouchFingerprintUnsupported": "このプロファイルはタッチ端末を名乗っており、ボットは操作できません。デスクトップのフィンガープリントをお使いください。"
},
"rail": {
"profiles": "プロファイル",
@@ -1870,7 +1899,9 @@
},
"network": "ネットワーク",
"integrations": "連携",
"account": "アカウント"
"account": "アカウント",
"cookieBot": "Cookie Bot",
"cookieBotRunning": "Cookie Bot — 実行中"
},
"pageTitle": {
"proxies": "ネットワーク",
@@ -1881,7 +1912,8 @@
"integrations": "連携",
"account": "アカウント",
"import": "プロファイルをインポート",
"shortcuts": "キーボードショートカット"
"shortcuts": "キーボードショートカット",
"cookieBot": "Cookie Bot"
},
"encryption": {
"required": {
@@ -1924,7 +1956,8 @@
},
"tabs": {
"account": "アカウント",
"selfHosted": "セルフホスト"
"selfHosted": "セルフホスト",
"teamUsage": "チームの使用状況"
},
"selfHosted": {
"title": "セルフホスト同期サーバー",
@@ -1937,7 +1970,8 @@
},
"deviceOrdinal": "{{count}} 台中 {{ordinal}} 台目",
"automationPrimaryOnly": "ブラウザの自動化はプライマリデバイス(デバイス1)でのみ実行できます。ここで使用するには、そのデバイスでサインアウトしてください。",
"automationActiveHere": "ブラウザの自動化はこのデバイスで有効です。"
"automationActiveHere": "ブラウザの自動化はこのデバイスで有効です。",
"viewTeamUsage": "チームの使用状況を表示"
},
"shortcutsPage": {
"title": "キーボードショートカット",
@@ -1970,7 +2004,8 @@
"goGroups": "グループへ移動",
"goIntegrations": "統合へ移動",
"goAccount": "アカウントへ移動",
"goSettings": "設定へ移動"
"goSettings": "設定へ移動",
"goCookieBot": "Cookie Bot"
},
"closeConfirm": {
"title": "Donut Browser を閉じますか?",
@@ -2102,5 +2137,287 @@
"matchToProxy": "フィンガープリントをプロキシに合わせる",
"matching": "調整中…",
"matchSuccess": "フィンガープリントをプロキシに合わせて更新しました。反映するにはプロファイルを再起動してください。"
},
"cookieBot": {
"title": "Cookie Bot",
"description": "リモートマシンで夜間にプロファイルをウォームアップします。",
"tabs": {
"overview": "概要",
"schedule": "スケジュール",
"activity": "アクティビティ",
"team": "チーム"
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。Pro または Team プランが必要です。"
},
"empty": {
"title": "登録されたプロファイルはありません",
"hint": "プロファイルを選ぶと、ボットが夜間にリモートマシンでウォームアップします。お使いのコンピューターは電源を切っていて構いません。",
"cta": "プロファイルを登録"
},
"tonight": {
"label": "今夜",
"nextRun": "次回の実行 {{time}}",
"dueCount_one": "{{count}} 件のプロファイルが予定",
"dueCount_other": "{{count}} 件のプロファイルが予定",
"nothingScheduled": "予定はありません"
},
"lastDay": {
"label": "過去 24 時間",
"none": "実行はまだありません",
"ran_one": "{{count}} 件実行",
"ran_other": "{{count}} 件実行",
"partial_one": "{{count}} 件が部分完了",
"partial_other": "{{count}} 件が部分完了",
"failed_one": "{{count}} 件が失敗",
"failed_other": "{{count}} 件が失敗"
},
"chart": {
"machineTime": "1 晩あたりのマシン時間",
"minutes": "{{minutes}} 分"
},
"hours": {
"label": "リモート時間",
"remaining": "{{total}} 時間のうち残り {{remaining}} 時間",
"remainingOf": "/ {{total}} 時間",
"used": "{{total}} 時間のうち {{used}} 時間を使用",
"resets": "{{date}} にリセット",
"exhausted": "リモート時間を使い切りました。スケジュールは登録されたままで、次のサイクルで再開します。",
"exhaustedOn": "リモート時間を使い切りました。スケジュールは登録されたままで、{{date}} に再開します。",
"estimate": "週あたり約 {{hours}} 時間 · 今サイクルの残り {{remaining}} 時間",
"estimateOverBudget": "週あたり約 {{hours}} 時間が必要ですが、残りは {{remaining}} 時間のみです",
"estimateOnly": "週あたり約 {{hours}} 時間"
},
"enrolled": {
"columnProfile": "プロファイル",
"columnCadence": "頻度",
"columnTime": "時刻",
"columnNextRun": "次回の実行",
"columnLastRun": "前回の実行",
"enrolProfiles": "プロファイルを登録",
"edit": "スケジュールを編集",
"neverRun": "なし",
"pausedNoHours": "一時停止 — 時間切れ",
"profileMissing": "そのプロファイルはこのコンピューターにありません。"
},
"schedule": {
"empty": "まだ何も予定されていません。",
"quietHours_one": "空き時間 {{count}} 時間",
"quietHours_other": "空き時間 {{count}} 時間",
"crowded_one": "{{count}} 件のプロファイルが同時に開始",
"crowded_other": "{{count}} 件のプロファイルが同時に開始",
"unenrol": "Cookie Bot から削除",
"unenrolTitle": "{{name}} を Cookie Bot から削除しますか?",
"unenrolDescription": "今夜からウォームアップを停止します。過去の実行はアクティビティに残ります。",
"unenrolled": "Cookie Bot から削除しました"
},
"status": {
"provisioning": "マシンを準備中",
"ready": "プロファイルを読み込み中",
"warming": "ウォームアップ中",
"finished": "終了処理中",
"failed": "失敗"
},
"runStatus": {
"pending": "待機中",
"running": "実行中",
"succeeded": "完了",
"partial": "部分完了",
"failed": "失敗",
"skipped": "スキップ",
"cancelled": "停止"
},
"running": {
"label": "実行中",
"more": "他 {{count}} 件",
"stop": "実行を停止",
"stopped": "実行を停止しました"
},
"live": {
"idle": "現在実行中のものはありません",
"streamOffline": "ライブ更新はオフラインです",
"streamOfflineDetail": "ライブ更新はオフラインです。再接続しています。以下の実行は古い可能性があります。",
"unnamedSession": "リモートセッション",
"notStartedYet": "マシンから開始時刻がまだ報告されていません。",
"sitesProgress": "{{total}} サイト中 {{visited}} サイト",
"sitesUnknown": "サイトはまだ報告されていません",
"consentHandled_one": "同意ダイアログを {{count}} 件処理",
"consentHandled_other": "同意ダイアログを {{count}} 件処理",
"consentUnknown": "同意ダイアログはまだ報告されていません",
"billed": "{{duration}} を課金",
"billedUnknown": "課金時間はまだ報告されていません",
"chunk": "{{total}} 分割中 {{index}} 番目"
},
"history": {
"title": "実行履歴",
"allProfiles": "すべてのプロファイル",
"searchPlaceholder": "プロファイルを検索…",
"filterAll": "すべての実行",
"filterComplete": "完了",
"filterPartial": "部分完了",
"filterFailed": "失敗",
"columnStarted": "開始",
"columnProfile": "プロファイル",
"columnDuration": "所要時間",
"columnSites": "サイト",
"columnStatus": "ステータス",
"columnOperator": "実行者",
"empty": "実行はまだありません。",
"noMatch": "このフィルターに一致する実行はありません。",
"unknownProfile": "不明なプロファイル",
"outcome": "理由: {{reason}}",
"sitesVisited": "{{visited}}/{{total}}",
"sitesFailed_one": "{{count}} サイトに接続できませんでした",
"sitesFailed_other": "{{count}} サイトに接続できませんでした",
"consentHandled_one": "同意ダイアログを {{count}} 件処理",
"consentHandled_other": "同意ダイアログを {{count}} 件処理",
"sitesUnknown": "未報告"
},
"duration": {
"hm": "{{hours}} 時間 {{minutes}} 分",
"ms": "{{minutes}} 分 {{seconds}} 秒",
"s": "{{seconds}} 秒"
},
"picker": {
"title": "プロファイルを登録",
"description": "ボットが夜間にウォームアップするプロファイルを選びます。",
"searchPlaceholder": "プロファイルを検索…",
"noProfiles": "一致するプロファイルはありません。",
"alreadyEnrolled": "登録済み",
"continue_one": "{{count}} 件で続行",
"continue_other": "{{count}} 件で続行"
},
"enrol": {
"titleOne": "{{name}} を登録",
"titleCount_one": "{{count}} 件のプロファイルを登録",
"titleCount_other": "{{count}} 件のプロファイルを登録",
"editTitle": "スケジュールを編集",
"description": "ボットはリモートマシンでプロファイルを開き、指定したサイトを閲覧します。お使いのコンピューターは電源を切っていて構いません。",
"summaryNightly": "毎晩 {{time}} に、1 回あたり最大 {{minutes}} 分実行します。",
"summaryWeeknights": "月曜から金曜の {{time}} に、1 回あたり最大 {{minutes}} 分実行します。",
"summaryAlternate": "1 晩おきに {{time}} に、1 回あたり最大 {{minutes}} 分実行します。",
"summaryCustom_one": "週 {{count}} 晩、{{time}} に、1 回あたり最大 {{minutes}} 分実行します。",
"summaryCustom_other": "週 {{count}} 晩、{{time}} に、1 回あたり最大 {{minutes}} 分実行します。",
"confirm": "今夜から登録",
"confirmSome": "今夜は {{total}} 件中 {{eligible}} 件を登録",
"fixFirst": "先にこちらを解決してください",
"saving": "登録中…",
"saved": "スケジュールを保存しました",
"enrolled_one": "{{count}} 件のプロファイルを登録しました",
"enrolled_other": "{{count}} 件のプロファイルを登録しました",
"adjust": "スケジュールを調整",
"cadenceLabel": "頻度",
"cadenceNightly": "毎晩",
"cadenceWeeknights": "平日の夜",
"cadenceAlternate": "1 晩おき",
"cadenceCustom_one": "週 {{count}} 晩",
"cadenceCustom_other": "週 {{count}} 晩",
"timeLabel": "開始時刻",
"timeHint": "各プロファイルのフィンガープリントのタイムゾーン基準です。",
"maxMinutesLabel": "最大分数",
"intensityLabel": "深さ",
"sitesLabel": "サイト",
"sitesPlaceholder": "1 行に 1 つのアドレス",
"sitesHint_one": "{{count}} サイト。ここに記載したページのみ閲覧します。",
"sitesHint_other": "{{count}} サイト。ここに記載したページのみ閲覧します。",
"sitesTooMany": "サイトは最大 {{max}} 件までです。",
"presetsUnavailable": "深さのプリセットを読み込めませんでした。しばらくしてからもう一度お試しください。",
"confirmBulkTitle_one": "{{count}} 件のプロファイルを Cookie Bot に登録しますか?",
"confirmBulkTitle_other": "{{count}} 件のプロファイルを Cookie Bot に登録しますか?",
"confirmBulkDescription_one": "共有のリモート時間から夜間の実行を 1 件予約します。時刻とサイトは次の画面で選びます。",
"confirmBulkDescription_other": "共有のリモート時間から夜間の実行を {{count}} 件予約します。時刻とサイトは次の画面で選びます。",
"confirmBulkButton_one": "{{count}} 件のプロファイルで続行",
"confirmBulkButton_other": "{{count}} 件のプロファイルで続行",
"sitesRequired": "サイトを 1 件以上追加してください。",
"addSitesFirst": "先にサイトを追加してください",
"presetsMissing": "深さのプリセットを利用できません"
},
"preset": {
"light": "軽め",
"balanced": "標準",
"deep": "深め"
},
"preflight": {
"ineligible_one": "{{count}} 件のプロファイルはリモートで実行できません",
"ineligible_other": "{{count}} 件のプロファイルはリモートで実行できません",
"reasonSync": "同期がオフです",
"reasonEncrypted": "エンドツーエンド暗号化",
"reasonNoFingerprint": "記録された OS がありません",
"reasonCrossOs": "{{os}} 向け — 一致するリモートマシンがありません",
"reasonNoExitNode": "プロキシも VPN もありません",
"fixSync": "同期をオンにする",
"fixEncrypted": "同期設定を開く",
"fixProxy": "プロキシを割り当てる",
"fixFailed": "その修正を適用できませんでした。",
"exitNodeHint": "プロキシも VPN もない場合、通信はマシン群自身のデータセンターのアドレスから出ていきます。ホスティングネットワークからの長時間のトラフィックは、ウォームアップしない場合よりもプロファイルの信頼性を損ないます。"
},
"conflict": {
"title": "{{email}} がすでにこのプロファイルを {{time}} にウォームアップしています",
"detail": "1 つのプロファイルに 2 つのスケジュールがあると時間が二重に消費され、実行中に衝突する可能性があります。",
"keepTheirs": "相手のスケジュールを使う",
"replace": "自分のもので置き換える",
"replaceForbidden": "他の人のスケジュールを変更できるのはチームのオーナーまたは管理者のみです。",
"askThem": "{{email}} をコピー",
"emailCopied": "メールアドレスをコピーしました",
"copyFailed": "アドレスをコピーできませんでした。"
},
"actionBar": {
"enrol": "Cookie Bot に登録",
"proRequired": "Cookie Bot には Pro または Team プランが必要です",
"noneEligible": "選択したプロファイルはいずれもリモートでウォームアップできません"
},
"actions": {
"enrol": "Cookie Bot に登録",
"editSchedule": "スケジュールを編集",
"runNow": "今すぐ実行",
"runStarted": "実行を開始しました",
"viewActivity": "アクティビティを表示",
"runNotStarted": "実行を開始できませんでした: {{reason}}"
},
"state": {
"enrolled": "登録済み",
"notEnrolled": "未登録",
"paused": "一時停止",
"summary": "{{cadence}} {{time}}",
"rowMenu": "{{name}} の Cookie Bot オプション",
"blocked": "実行できません: {{reason}}"
},
"team": {
"title": "チームの使用状況",
"pooled_one": "{{total}} 時間中 {{used}} 時間、{{count}} シート",
"pooled_other": "{{total}} 時間中 {{used}} 時間を {{count}} シートで共有",
"hours": "{{hours}} 時間",
"legendBot": "Cookie Bot",
"legendInteractive": "対話操作",
"columnMember": "メンバー",
"columnRuns": "実行回数",
"columnHours": "時間",
"columnShare": "割合",
"noActivity": "この期間にリモートセッションはありません。",
"soloNote": "今のところ表示されるのはご自身の使用状況のみです。メンバーごとの内訳を見るにはチームメイトを招待してください。",
"runsFailed": "・失敗 {{n}} 件"
},
"closeReason": {
"stoppedByUser": "手動で停止しました",
"maxDuration": "時間の上限に達しました",
"other": "終了: {{reason}}"
},
"outcome": {
"notEntitled": "現在のプランには含まれていません",
"syncDisabled": "このプロファイルはクラウド同期がオフです",
"encryptedSync": "エンドツーエンド暗号化された同期には対応していません",
"proxyRequired": "プロキシまたは VPN が設定されていません",
"touchFingerprint": "タッチ端末のフィンガープリントには対応していません",
"platformUnsupported": "このプロファイルの OS を実行できるマシンがありません",
"noSites": "訪問するサイトがありません",
"quotaExhausted": "リモート時間を使い切りました",
"profileLocked": "プロファイルが別の場所で開かれていました",
"noCapacity": "空きマシンがありませんでした",
"managerError": "フリートがブラウザーを起動できませんでした",
"budgetExceeded": "その晩の持ち時間を使い切りました",
"cancelledByUser": "手動で停止しました",
"unknown": "不明な理由 ({{code}})"
}
}
}
+324 -7
View File
@@ -258,7 +258,8 @@
"emptyCreate": "프로필 생성",
"emptyImport": "프로필 가져오기",
"emptyFilteredTitle": "프로필을 찾을 수 없습니다",
"emptyFilteredHint": "이 그룹 또는 검색과 일치하는 프로필이 없습니다. 다른 필터를 사용하거나 새로 만드세요."
"emptyFilteredHint": "이 그룹 또는 검색과 일치하는 프로필이 없습니다. 다른 필터를 사용하거나 새로 만드세요.",
"bot": "봇"
},
"actions": {
"launch": "실행",
@@ -1851,7 +1852,35 @@
"vlessConfigInvalid": "VLESS URI가 올바르지 않습니다.",
"xrayUnavailable": "이 시스템에서는 Xray-core를 사용할 수 없습니다.",
"xrayUnsupportedOs": "VLESS를 사용하려면 macOS 12 이상이 필요합니다.",
"xrayStartFailed": "Xray-core를 시작할 수 없습니다."
"xrayStartFailed": "Xray-core를 시작할 수 없습니다.",
"cloudNotSignedIn": "이 기능을 사용하려면 Donut Browser 계정에 로그인하세요.",
"cloudUnreachable": "Donut Browser 서버에 연결하지 못했습니다. 연결 상태를 확인하고 다시 시도하세요.",
"cloudRequestFailed": "요청이 실패했습니다. 잠시 후 다시 시도하세요.",
"remoteRateLimited": "요청이 너무 많습니다. 잠시 기다렸다가 다시 시도하세요.",
"remoteNoCapacity": "지금은 사용 가능한 원격 머신이 없습니다. 몇 분 후에 다시 시도하세요.",
"remoteNotEntitled": "현재 요금제에는 원격 실행이 포함되어 있지 않습니다.",
"remoteSessionRefused": "원격 머신이 이 세션을 거부했습니다.",
"remoteSessionNotFound": "해당 원격 세션은 더 이상 존재하지 않습니다.",
"remoteSessionConflict": "이 프로필은 이미 다른 곳에서 열려 있습니다.",
"remoteSyncInProgress": "이 프로필은 아직 클라우드 동기화에 업로드 중입니다. 동기화가 끝난 뒤 다시 시도하세요.",
"remoteHoursExhausted": "이번 달 원격 시간 {{granted}}시간 중 {{used}}시간을 모두 사용했습니다.",
"notTeamMember": "어떤 팀에도 속해 있지 않습니다.",
"cookieBotNotEntitled": "현재 요금제에는 Cookie Bot이 포함되어 있지 않습니다.",
"cookieBotNotEnrolled": "이 프로필은 아직 Cookie Bot용으로 설정되지 않았습니다.",
"cookieBotScheduleConflict": "{{email}} 님이 이미 이 프로필을 {{time}}에 예열합니다.",
"cookieBotRunInProgress": "이 프로필에서 이미 실행이 진행 중입니다.",
"cookieBotRunNotFound": "해당 실행은 더 이상 존재하지 않습니다.",
"cookieBotInvalidSchedule": "일정이 올바르지 않습니다. 실행 시각, 요일, 시간을 확인하세요.",
"cookieBotInvalidTimezone": "{{timezone}}은(는) 서버가 인식하지 못하는 시간대입니다.",
"cookieBotInvalidPeriod": "기간이 올바르지 않습니다. 2026-08 같은 월 형식을 사용하세요.",
"cookieBotSiteLimit": "사이트를 {{min}}개 이상 {{max}}개 이하로, 각각 완전한 http 또는 https 주소로 입력하세요.",
"cookieBotRequiresCloudSync": "먼저 이 프로필의 클라우드 동기화를 켜세요. 원격 머신이 프로필을 가져올 다른 방법이 없습니다.",
"cookieBotEncryptedSyncUnsupported": "이 프로필은 원격 머신이 복호화할 수 없는 종단 간 암호화 동기화를 사용합니다. 일반 동기화로 전환하세요.",
"cookieBotUnknownPlatform": "이 프로필에는 기록된 운영체제가 없어 머신을 배정할 수 없습니다.",
"cookieBotUnsupportedPlatform": "Cookie Bot은 {{platform}} 프로필을 실행할 수 없습니다. Windows와 macOS 프로필만 지원합니다.",
"cookieBotRequiresExitNode": "먼저 프록시나 VPN을 연결하세요. 없으면 실행 트래픽이 데이터센터 주소에서 나가 프로필 신뢰도를 해칩니다.",
"unknownCode": "문제가 발생했습니다: {{code}}",
"cookieBotTouchFingerprintUnsupported": "이 프로필은 터치 기기를 표방하며, 봇이 조작할 수 없습니다. 데스크톱 지문을 사용하세요."
},
"rail": {
"profiles": "프로필",
@@ -1870,7 +1899,9 @@
},
"network": "네트워크",
"integrations": "통합",
"account": "계정"
"account": "계정",
"cookieBot": "Cookie Bot",
"cookieBotRunning": "Cookie Bot — 실행 중"
},
"pageTitle": {
"proxies": "네트워크",
@@ -1881,7 +1912,8 @@
"integrations": "통합",
"account": "계정",
"import": "프로필 가져오기",
"shortcuts": "키보드 단축키"
"shortcuts": "키보드 단축키",
"cookieBot": "Cookie Bot"
},
"encryption": {
"required": {
@@ -1924,7 +1956,8 @@
},
"tabs": {
"account": "계정",
"selfHosted": "자체 호스팅"
"selfHosted": "자체 호스팅",
"teamUsage": "팀 사용량"
},
"selfHosted": {
"title": "자체 호스팅 동기화 서버",
@@ -1937,7 +1970,8 @@
},
"deviceOrdinal": "{{count}}대 중 {{ordinal}}번째",
"automationPrimaryOnly": "브라우저 자동화는 기본 기기(기기 1)에서만 실행됩니다. 여기서 사용하려면 해당 기기에서 로그아웃하세요.",
"automationActiveHere": "이 기기에서 브라우저 자동화가 활성화되어 있습니다."
"automationActiveHere": "이 기기에서 브라우저 자동화가 활성화되어 있습니다.",
"viewTeamUsage": "팀 사용량 보기"
},
"shortcutsPage": {
"title": "키보드 단축키",
@@ -1970,7 +2004,8 @@
"goGroups": "그룹으로 이동",
"goIntegrations": "통합으로 이동",
"goAccount": "계정으로 이동",
"goSettings": "설정으로 이동"
"goSettings": "설정으로 이동",
"goCookieBot": "Cookie Bot"
},
"closeConfirm": {
"title": "Donut Browser를 닫으시겠습니까?",
@@ -2102,5 +2137,287 @@
"matchToProxy": "지문을 프록시에 맞추기",
"matching": "맞추는 중…",
"matchSuccess": "지문이 프록시에 맞게 업데이트되었습니다. 적용하려면 프로필을 다시 실행하세요."
},
"cookieBot": {
"title": "Cookie Bot",
"description": "원격 머신에서 밤새 프로필을 예열합니다.",
"tabs": {
"overview": "개요",
"schedule": "일정",
"activity": "활동",
"team": "팀"
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다. Pro 또는 Team 요금제가 필요합니다."
},
"empty": {
"title": "등록된 프로필이 없습니다",
"hint": "프로필을 선택하면 봇이 밤새 원격 머신에서 예열합니다. 내 컴퓨터는 꺼져 있어도 됩니다.",
"cta": "프로필 등록"
},
"tonight": {
"label": "오늘 밤",
"nextRun": "다음 실행 {{time}}",
"dueCount_one": "프로필 {{count}}개 예정",
"dueCount_other": "프로필 {{count}}개 예정",
"nothingScheduled": "예정된 작업 없음"
},
"lastDay": {
"label": "최근 24시간",
"none": "아직 실행 없음",
"ran_one": "{{count}}건 실행",
"ran_other": "{{count}}건 실행",
"partial_one": "{{count}}건 일부 완료",
"partial_other": "{{count}}건 일부 완료",
"failed_one": "{{count}}건 실패",
"failed_other": "{{count}}건 실패"
},
"chart": {
"machineTime": "하룻밤당 머신 시간",
"minutes": "{{minutes}}분"
},
"hours": {
"label": "원격 시간",
"remaining": "{{total}}시간 중 {{remaining}}시간 남음",
"remainingOf": "/ {{total}}시간",
"used": "{{total}}시간 중 {{used}}시간 사용",
"resets": "{{date}}에 초기화",
"exhausted": "남은 원격 시간이 없습니다. 일정은 그대로 등록되어 있으며 다음 주기에 재개됩니다.",
"exhaustedOn": "남은 원격 시간이 없습니다. 일정은 그대로 등록되어 있으며 {{date}}에 재개됩니다.",
"estimate": "주당 약 {{hours}}시간 · 이번 주기에 {{remaining}}시간 남음",
"estimateOverBudget": "주당 약 {{hours}}시간이 필요하지만 {{remaining}}시간만 남았습니다",
"estimateOnly": "주당 약 {{hours}}시간"
},
"enrolled": {
"columnProfile": "프로필",
"columnCadence": "주기",
"columnTime": "시각",
"columnNextRun": "다음 실행",
"columnLastRun": "마지막 실행",
"enrolProfiles": "프로필 등록",
"edit": "일정 편집",
"neverRun": "없음",
"pausedNoHours": "일시 중지 — 시간 소진",
"profileMissing": "해당 프로필은 이 컴퓨터에 없습니다."
},
"schedule": {
"empty": "아직 예정된 작업이 없습니다.",
"quietHours_one": "빈 시간 {{count}}시간",
"quietHours_other": "빈 시간 {{count}}시간",
"crowded_one": "프로필 {{count}}개가 동시에 시작",
"crowded_other": "프로필 {{count}}개가 동시에 시작",
"unenrol": "Cookie Bot에서 제거",
"unenrolTitle": "{{name}}을(를) Cookie Bot에서 제거할까요?",
"unenrolDescription": "오늘 밤부터 예열이 중단됩니다. 지난 실행 기록은 활동 탭에 남습니다.",
"unenrolled": "Cookie Bot에서 제거했습니다"
},
"status": {
"provisioning": "머신 준비 중",
"ready": "프로필 불러오는 중",
"warming": "예열 중",
"finished": "마무리 중",
"failed": "실패"
},
"runStatus": {
"pending": "대기 중",
"running": "실행 중",
"succeeded": "완료",
"partial": "일부 완료",
"failed": "실패",
"skipped": "건너뜀",
"cancelled": "중지됨"
},
"running": {
"label": "지금 실행 중",
"more": "+{{count}}개 더",
"stop": "실행 중지",
"stopped": "실행을 중지했습니다"
},
"live": {
"idle": "지금 실행 중인 작업이 없습니다",
"streamOffline": "실시간 업데이트가 오프라인입니다",
"streamOfflineDetail": "실시간 업데이트가 오프라인입니다. 다시 연결하는 중입니다. 아래 실행 내역은 최신이 아닐 수 있습니다.",
"unnamedSession": "원격 세션",
"notStartedYet": "머신이 아직 시작 시각을 보고하지 않았습니다.",
"sitesProgress": "{{total}}개 사이트 중 {{visited}}개",
"sitesUnknown": "사이트가 아직 보고되지 않았습니다",
"consentHandled_one": "동의 창 {{count}}개 처리",
"consentHandled_other": "동의 창 {{count}}개 처리",
"consentUnknown": "동의 창이 아직 보고되지 않았습니다",
"billed": "{{duration}} 청구",
"billedUnknown": "청구 시간이 아직 보고되지 않았습니다",
"chunk": "{{total}}개 중 {{index}}번째"
},
"history": {
"title": "실행 내역",
"allProfiles": "모든 프로필",
"searchPlaceholder": "프로필 검색…",
"filterAll": "모든 실행",
"filterComplete": "완료",
"filterPartial": "일부 완료",
"filterFailed": "실패",
"columnStarted": "시작",
"columnProfile": "프로필",
"columnDuration": "소요 시간",
"columnSites": "사이트",
"columnStatus": "상태",
"columnOperator": "실행자",
"empty": "아직 실행 내역이 없습니다.",
"noMatch": "이 필터에 해당하는 실행이 없습니다.",
"unknownProfile": "알 수 없는 프로필",
"outcome": "이유: {{reason}}",
"sitesVisited": "{{visited}}/{{total}}",
"sitesFailed_one": "사이트 {{count}}개에 접속하지 못했습니다",
"sitesFailed_other": "사이트 {{count}}개에 접속하지 못했습니다",
"consentHandled_one": "동의 창 {{count}}개 처리",
"consentHandled_other": "동의 창 {{count}}개 처리",
"sitesUnknown": "보고되지 않음"
},
"duration": {
"hm": "{{hours}}시간 {{minutes}}분",
"ms": "{{minutes}}분 {{seconds}}초",
"s": "{{seconds}}초"
},
"picker": {
"title": "프로필 등록",
"description": "봇이 밤새 예열할 프로필을 선택하세요.",
"searchPlaceholder": "프로필 검색…",
"noProfiles": "일치하는 프로필이 없습니다.",
"alreadyEnrolled": "등록됨",
"continue_one": "{{count}}개로 계속",
"continue_other": "{{count}}개로 계속"
},
"enrol": {
"titleOne": "{{name}} 등록",
"titleCount_one": "프로필 {{count}}개 등록",
"titleCount_other": "프로필 {{count}}개 등록",
"editTitle": "일정 편집",
"description": "봇이 원격 머신에서 프로필을 열고 지정한 사이트를 둘러봅니다. 내 컴퓨터는 꺼져 있어도 됩니다.",
"summaryNightly": "매일 밤 {{time}}에 회당 최대 {{minutes}}분 실행합니다.",
"summaryWeeknights": "월요일부터 금요일까지 {{time}}에 회당 최대 {{minutes}}분 실행합니다.",
"summaryAlternate": "하루 걸러 {{time}}에 회당 최대 {{minutes}}분 실행합니다.",
"summaryCustom_one": "주 {{count}}회 밤 {{time}}에 회당 최대 {{minutes}}분 실행합니다.",
"summaryCustom_other": "주 {{count}}회 밤 {{time}}에 회당 최대 {{minutes}}분 실행합니다.",
"confirm": "오늘 밤부터 등록",
"confirmSome": "오늘 밤 {{total}}개 중 {{eligible}}개 등록",
"fixFirst": "먼저 이 문제를 해결하세요",
"saving": "등록 중…",
"saved": "일정을 저장했습니다",
"enrolled_one": "프로필 {{count}}개를 등록했습니다",
"enrolled_other": "프로필 {{count}}개를 등록했습니다",
"adjust": "일정 조정",
"cadenceLabel": "주기",
"cadenceNightly": "매일 밤",
"cadenceWeeknights": "평일 밤",
"cadenceAlternate": "하루 걸러",
"cadenceCustom_one": "주 {{count}}회 밤",
"cadenceCustom_other": "주 {{count}}회 밤",
"timeLabel": "시작 시각",
"timeHint": "각 프로필 지문의 시간대 기준입니다.",
"maxMinutesLabel": "최대 분",
"intensityLabel": "깊이",
"sitesLabel": "사이트",
"sitesPlaceholder": "한 줄에 주소 하나",
"sitesHint_one": "사이트 {{count}}개. 여기에 적은 페이지만 방문합니다.",
"sitesHint_other": "사이트 {{count}}개. 여기에 적은 페이지만 방문합니다.",
"sitesTooMany": "사이트는 최대 {{max}}개입니다.",
"presetsUnavailable": "깊이 프리셋을 불러오지 못했습니다. 잠시 후 다시 시도하세요.",
"confirmBulkTitle_one": "프로필 {{count}}개를 Cookie Bot에 등록할까요?",
"confirmBulkTitle_other": "프로필 {{count}}개를 Cookie Bot에 등록할까요?",
"confirmBulkDescription_one": "공유 원격 시간에서 야간 실행 1건을 예약합니다. 시각과 사이트는 다음 단계에서 선택합니다.",
"confirmBulkDescription_other": "공유 원격 시간에서 야간 실행 {{count}}건을 예약합니다. 시각과 사이트는 다음 단계에서 선택합니다.",
"confirmBulkButton_one": "프로필 {{count}}개로 계속",
"confirmBulkButton_other": "프로필 {{count}}개로 계속",
"sitesRequired": "사이트를 하나 이상 추가하세요.",
"addSitesFirst": "사이트를 먼저 추가하세요",
"presetsMissing": "깊이 프리셋을 사용할 수 없습니다"
},
"preset": {
"light": "가볍게",
"balanced": "표준",
"deep": "깊게"
},
"preflight": {
"ineligible_one": "프로필 {{count}}개는 원격으로 실행할 수 없습니다",
"ineligible_other": "프로필 {{count}}개는 원격으로 실행할 수 없습니다",
"reasonSync": "동기화가 꺼져 있음",
"reasonEncrypted": "종단 간 암호화",
"reasonNoFingerprint": "기록된 운영체제 없음",
"reasonCrossOs": "{{os}}용으로 생성됨 — 일치하는 원격 머신 없음",
"reasonNoExitNode": "프록시나 VPN 없음",
"fixSync": "동기화 켜기",
"fixEncrypted": "동기화 설정 열기",
"fixProxy": "프록시 연결",
"fixFailed": "해당 수정을 적용하지 못했습니다.",
"exitNodeHint": "프록시나 VPN이 없으면 실행 트래픽이 머신 풀 자체의 데이터센터 주소로 나갑니다. 호스팅 네트워크에서 몇 시간씩 발생하는 트래픽은 예열을 아예 하지 않는 것보다 프로필 신뢰도를 더 크게 해칩니다."
},
"conflict": {
"title": "{{email}} 님이 이미 이 프로필을 {{time}}에 예열합니다",
"detail": "한 프로필에 일정이 두 개면 시간이 두 배로 소모되고 실행 도중 충돌할 수 있습니다.",
"keepTheirs": "상대의 일정 사용",
"replace": "내 일정으로 교체",
"replaceForbidden": "다른 사람의 일정은 팀 소유자나 관리자만 변경할 수 있습니다.",
"askThem": "{{email}} 복사",
"emailCopied": "이메일을 복사했습니다",
"copyFailed": "주소를 복사하지 못했습니다."
},
"actionBar": {
"enrol": "Cookie Bot에 등록",
"proRequired": "Cookie Bot에는 Pro 또는 Team 요금제가 필요합니다",
"noneEligible": "선택한 프로필 중 원격으로 예열할 수 있는 것이 없습니다"
},
"actions": {
"enrol": "Cookie Bot에 등록",
"editSchedule": "일정 편집",
"runNow": "지금 실행",
"runStarted": "실행을 시작했습니다",
"viewActivity": "활동 보기",
"runNotStarted": "실행이 시작되지 않았습니다: {{reason}}"
},
"state": {
"enrolled": "등록됨",
"notEnrolled": "등록되지 않음",
"paused": "일시 중지",
"summary": "{{cadence}} {{time}}",
"rowMenu": "{{name}}의 Cookie Bot 옵션",
"blocked": "실행할 수 없음: {{reason}}"
},
"team": {
"title": "팀 사용량",
"pooled_one": "{{total}}시간 중 {{used}}시간, {{count}}석",
"pooled_other": "{{total}}시간 중 {{used}}시간을 {{count}}석이 공유",
"hours": "{{hours}}시간",
"legendBot": "Cookie Bot",
"legendInteractive": "직접 사용",
"columnMember": "구성원",
"columnRuns": "실행 횟수",
"columnHours": "시간",
"columnShare": "비중",
"noActivity": "이 기간에는 원격 세션이 없습니다.",
"soloNote": "현재는 본인 사용량만 표시됩니다. 구성원별 분배를 보려면 팀원을 초대하세요.",
"runsFailed": "· 실패 {{n}}회"
},
"closeReason": {
"stoppedByUser": "직접 중지했습니다",
"maxDuration": "시간 제한에 도달했습니다",
"other": "종료됨: {{reason}}"
},
"outcome": {
"notEntitled": "현재 요금제에 포함되어 있지 않습니다",
"syncDisabled": "이 프로필은 클라우드 동기화가 꺼져 있습니다",
"encryptedSync": "종단 간 암호화 동기화는 지원되지 않습니다",
"proxyRequired": "프록시나 VPN이 연결되어 있지 않습니다",
"touchFingerprint": "터치 기기 지문은 지원되지 않습니다",
"platformUnsupported": "이 프로필의 운영체제를 실행할 머신이 없습니다",
"noSites": "방문할 사이트가 없습니다",
"quotaExhausted": "원격 시간을 모두 사용했습니다",
"profileLocked": "프로필이 다른 곳에서 열려 있었습니다",
"noCapacity": "여유 머신이 없었습니다",
"managerError": "플릿이 브라우저를 시작하지 못했습니다",
"budgetExceeded": "그날 밤의 시간 예산이 소진되었습니다",
"cancelledByUser": "직접 중지했습니다",
"unknown": "알 수 없는 이유 ({{code}})"
}
}
}
+351 -7
View File
@@ -258,7 +258,8 @@
"emptyCreate": "Criar perfil",
"emptyImport": "Importar perfis",
"emptyFilteredTitle": "Nenhum perfil encontrado",
"emptyFilteredHint": "Nenhum perfil corresponde a este grupo ou pesquisa. Tente outro filtro ou crie um novo."
"emptyFilteredHint": "Nenhum perfil corresponde a este grupo ou pesquisa. Tente outro filtro ou crie um novo.",
"bot": "Bot"
},
"actions": {
"launch": "Iniciar",
@@ -421,6 +422,7 @@
"deleteProxy": "Excluir proxy",
"cannotDelete_one": "Não é possível excluir: em uso por {{count}} perfil",
"cannotDelete_other": "Não é possível excluir: em uso por {{count}} perfis",
"cannotDelete_many": "Não é possível excluir: em uso por {{count}} perfis",
"syncEnabled": "Sincronização ativada",
"syncDisabled": "Sincronização desativada",
"updateSyncFailed": "Falha ao atualizar a sincronização",
@@ -814,6 +816,7 @@
"selectedCount_plural": "{{count}} cookies selecionados",
"dialogDescription_one": "Copiar cookies de um perfil de origem para {{count}} perfil selecionado.",
"dialogDescription_other": "Copiar cookies de um perfil de origem para {{count}} perfis selecionados.",
"dialogDescription_many": "Copiar cookies de um perfil de origem para {{count}} perfis selecionados.",
"sourceProfile": "Perfil de origem",
"sourcePlaceholder": "Selecione um perfil para copiar os cookies",
"running": "(em execução)",
@@ -832,6 +835,7 @@
"failedMessage": "Falha ao copiar cookies: {{error}}",
"copyButton_one": "Copiar {{count}} cookie",
"copyButton_other": "Copiar {{count}} cookies",
"copyButton_many": "Copiar {{count}} cookies",
"copyButtonEmpty": "Copiar cookies"
},
"success": "Cookies copiados com sucesso",
@@ -1403,6 +1407,7 @@
"deleteVpn": "Excluir VPN",
"cannotDelete_one": "Não é possível excluir: em uso por {{count}} perfil",
"cannotDelete_other": "Não é possível excluir: em uso por {{count}} perfis",
"cannotDelete_many": "Não é possível excluir: em uso por {{count}} perfis",
"syncCannotDisable": "A sincronização não pode ser desativada enquanto esta VPN estiver em uso por perfis sincronizados",
"deleteSuccess": "VPN excluída com sucesso",
"deleteFailed": "Falha ao excluir VPN",
@@ -1481,6 +1486,7 @@
"loading": "Carregando grupos...",
"profileCount_one": "{{count}} perfil",
"profileCount_other": "{{count}} perfis",
"profileCount_many": "{{count}} perfis",
"groupsLabel": "Grupos",
"profilesCol": "Perfis",
"syncCannotDisable": "A sincronização não pode ser desativada enquanto este grupo estiver em uso por perfis sincronizados",
@@ -1498,6 +1504,7 @@
"title": "Atribuir proxy / VPN",
"description_one": "Atribuir um proxy ou VPN a {{count}} perfil selecionado.",
"description_other": "Atribuir um proxy ou VPN a {{count}} perfis selecionados.",
"description_many": "Atribuir um proxy ou VPN a {{count}} perfis selecionados.",
"selectLabel": "Proxy / VPN",
"placeholder": "Selecione um proxy ou VPN",
"noProxy": "Sem proxy / VPN",
@@ -1517,6 +1524,7 @@
"title": "Atribuir grupo",
"description_one": "Atribuir um grupo a {{count}} perfil selecionado.",
"description_other": "Atribuir um grupo a {{count}} perfis selecionados.",
"description_many": "Atribuir um grupo a {{count}} perfis selecionados.",
"selectLabel": "Grupo",
"placeholder": "Selecione um grupo",
"noGroup": "Sem grupo (Padrão)",
@@ -1851,7 +1859,35 @@
"vlessConfigInvalid": "O URI VLESS é inválido.",
"xrayUnavailable": "O Xray-core não está disponível neste sistema.",
"xrayUnsupportedOs": "O VLESS requer macOS 12 ou posterior.",
"xrayStartFailed": "Não foi possível iniciar o Xray-core."
"xrayStartFailed": "Não foi possível iniciar o Xray-core.",
"cloudNotSignedIn": "Entre na sua conta do Donut Browser para usar este recurso.",
"cloudUnreachable": "Não foi possível contatar os servidores do Donut Browser. Verifique sua conexão e tente novamente.",
"cloudRequestFailed": "A solicitação falhou. Tente novamente em instantes.",
"remoteRateLimited": "Solicitações demais. Aguarde um momento e tente novamente.",
"remoteNoCapacity": "Nenhuma máquina remota está livre agora. Tente novamente em alguns minutos.",
"remoteNotEntitled": "Seu plano não inclui execução remota.",
"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.",
"remoteSyncInProgress": "Este perfil ainda está sendo enviado para a sincronização na nuvem. Aguarde a conclusão e tente novamente.",
"remoteHoursExhausted": "Você usou as {{used}} de suas {{granted}} horas remotas deste mês.",
"notTeamMember": "Você não faz parte de nenhuma equipe.",
"cookieBotNotEntitled": "Seu plano não inclui o Cookie Bot.",
"cookieBotNotEnrolled": "Este perfil ainda não está configurado para o Cookie Bot.",
"cookieBotScheduleConflict": "{{email}} já aquece este perfil às {{time}}.",
"cookieBotRunInProgress": "Já há uma execução em andamento para este perfil.",
"cookieBotRunNotFound": "Essa execução não existe mais.",
"cookieBotInvalidSchedule": "Essa agenda não é válida. Verifique o horário, os dias e a duração.",
"cookieBotInvalidTimezone": "{{timezone}} não é um fuso horário reconhecido pelo servidor.",
"cookieBotInvalidPeriod": "Esse período não é válido. Use um mês como 2026-08.",
"cookieBotSiteLimit": "Introduza entre {{min}} e {{max}} sites, cada um com um endereço http ou https completo.",
"cookieBotRequiresCloudSync": "Ative primeiro a sincronização na nuvem para este perfil: a máquina remota não tem outra forma de obtê-lo.",
"cookieBotEncryptedSyncUnsupported": "Este perfil usa sincronização criptografada de ponta a ponta, que a máquina remota não consegue descriptografar. Mude para a sincronização normal.",
"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.",
"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."
},
"rail": {
"profiles": "Perfis",
@@ -1870,7 +1906,9 @@
},
"network": "Rede",
"integrations": "Integrações",
"account": "Conta"
"account": "Conta",
"cookieBot": "Cookie Bot",
"cookieBotRunning": "Cookie Bot: em execução agora"
},
"pageTitle": {
"proxies": "Rede",
@@ -1881,7 +1919,8 @@
"integrations": "Integrações",
"account": "Conta",
"import": "Importar perfil",
"shortcuts": "Atalhos de teclado"
"shortcuts": "Atalhos de teclado",
"cookieBot": "Cookie Bot"
},
"encryption": {
"required": {
@@ -1924,7 +1963,8 @@
},
"tabs": {
"account": "Conta",
"selfHosted": "Auto-hospedado"
"selfHosted": "Auto-hospedado",
"teamUsage": "Uso da equipe"
},
"selfHosted": {
"title": "Servidor de sincronização auto-hospedado",
@@ -1937,7 +1977,8 @@
},
"deviceOrdinal": "{{ordinal}} de {{count}}",
"automationPrimaryOnly": "A automação do navegador funciona apenas no seu dispositivo principal (Dispositivo 1). Saia da conta nele para usá-la aqui.",
"automationActiveHere": "A automação do navegador está ativa neste dispositivo."
"automationActiveHere": "A automação do navegador está ativa neste dispositivo.",
"viewTeamUsage": "Ver o uso da equipe"
},
"shortcutsPage": {
"title": "Atalhos de teclado",
@@ -1970,7 +2011,8 @@
"goGroups": "Ir para Grupos",
"goIntegrations": "Ir para Integrações",
"goAccount": "Ir para Conta",
"goSettings": "Ir para Configurações"
"goSettings": "Ir para Configurações",
"goCookieBot": "Cookie Bot"
},
"closeConfirm": {
"title": "Fechar Donut Browser?",
@@ -2102,5 +2144,307 @@
"matchToProxy": "Ajustar impressão ao proxy",
"matching": "Ajustando…",
"matchSuccess": "Impressão digital atualizada para corresponder ao proxy. Reinicie o perfil para aplicar."
},
"cookieBot": {
"title": "Cookie Bot",
"description": "Aquecimento noturno de perfis em uma máquina remota.",
"tabs": {
"overview": "Visão geral",
"schedule": "Agenda",
"activity": "Atividade",
"team": "Equipe"
},
"locked": {
"title": "Cookie Bot",
"hint": "O Cookie Bot aquece seus perfis durante a noite em uma máquina remota, para que mantenham os cookies e o histórico sem o seu computador ligado. Requer um plano Pro ou Team."
},
"empty": {
"title": "Nenhum perfil inscrito",
"hint": "Escolha um perfil e o bot o aquece durante a noite em uma máquina remota. Seu computador pode ficar desligado.",
"cta": "Inscrever um perfil"
},
"tonight": {
"label": "Hoje à noite",
"nextRun": "Próxima execução {{time}}",
"dueCount_one": "{{count}} perfil pendente",
"dueCount_other": "{{count}} perfis pendentes",
"dueCount_many": "{{count}} perfis pendentes",
"nothingScheduled": "Nada agendado"
},
"lastDay": {
"label": "Últimas 24 horas",
"none": "Ainda sem execuções",
"ran_one": "{{count}} executado",
"ran_other": "{{count}} executados",
"ran_many": "{{count}} executados",
"partial_one": "{{count}} parcial",
"partial_other": "{{count}} parciais",
"partial_many": "{{count}} parciais",
"failed_one": "{{count}} com falha",
"failed_other": "{{count}} com falha",
"failed_many": "{{count}} com falha"
},
"chart": {
"machineTime": "Tempo de máquina por noite",
"minutes": "{{minutes}} min"
},
"hours": {
"label": "Horas remotas",
"remaining": "Restam {{remaining}} h de {{total}}",
"remainingOf": "de {{total}} h",
"used": "{{used}} de {{total}} h usadas",
"resets": "Renova em {{date}}",
"exhausted": "Sem horas remotas restantes. As agendas continuam inscritas e retomam no próximo ciclo.",
"exhaustedOn": "Sem horas remotas restantes. As agendas continuam inscritas e retomam em {{date}}.",
"estimate": "Cerca de {{hours}} h por semana · restam {{remaining}} h neste ciclo",
"estimateOverBudget": "Precisa de cerca de {{hours}} h por semana: restam apenas {{remaining}} h",
"estimateOnly": "Cerca de {{hours}} h por semana"
},
"enrolled": {
"columnProfile": "Perfil",
"columnCadence": "Frequência",
"columnTime": "Horário",
"columnNextRun": "Próxima execução",
"columnLastRun": "Última execução",
"enrolProfiles": "Inscrever perfis",
"edit": "Editar agenda",
"neverRun": "Nunca",
"pausedNoHours": "Pausado: sem horas",
"profileMissing": "Esse perfil não está neste computador."
},
"schedule": {
"empty": "Ainda não há nada agendado.",
"quietHours_one": "{{count}} hora livre",
"quietHours_other": "{{count}} horas livres",
"quietHours_many": "{{count}} horas livres",
"crowded_one": "{{count}} perfil começa ao mesmo tempo",
"crowded_other": "{{count}} perfis começam ao mesmo tempo",
"crowded_many": "{{count}} perfis começam ao mesmo tempo",
"unenrol": "Remover do Cookie Bot",
"unenrolTitle": "Remover {{name}} do Cookie Bot?",
"unenrolDescription": "Ele deixa de aquecer hoje à noite. As execuções anteriores permanecem em Atividade.",
"unenrolled": "Removido do Cookie Bot"
},
"status": {
"provisioning": "Preparando uma máquina",
"ready": "Carregando o perfil",
"warming": "Aquecendo",
"finished": "Finalizando",
"failed": "Com falha"
},
"runStatus": {
"pending": "Na fila",
"running": "Em execução",
"succeeded": "Concluída",
"partial": "Parcial",
"failed": "Com falha",
"skipped": "Ignorada",
"cancelled": "Interrompida"
},
"running": {
"label": "Em execução agora",
"more": "+{{count}} mais",
"stop": "Parar a execução",
"stopped": "Execução interrompida"
},
"live": {
"idle": "Nada em execução no momento",
"streamOffline": "As atualizações ao vivo estão offline",
"streamOfflineDetail": "As atualizações ao vivo estão offline; reconectando. As execuções abaixo podem estar desatualizadas.",
"unnamedSession": "Sessão remota",
"notStartedYet": "A máquina ainda não informou um horário de início.",
"sitesProgress": "{{visited}} de {{total}} sites",
"sitesUnknown": "Sites ainda não informados",
"consentHandled_one": "{{count}} aviso de consentimento tratado",
"consentHandled_other": "{{count}} avisos de consentimento tratados",
"consentHandled_many": "{{count}} avisos de consentimento tratados",
"consentUnknown": "Avisos de consentimento ainda não informados",
"billed": "{{duration}} cobrados",
"billedUnknown": "Tempo cobrado ainda não informado",
"chunk": "Parte {{index}} de {{total}}"
},
"history": {
"title": "Execuções",
"allProfiles": "Todos os perfis",
"searchPlaceholder": "Pesquisar perfis…",
"filterAll": "Todas as execuções",
"filterComplete": "Concluídas",
"filterPartial": "Parciais",
"filterFailed": "Com falha",
"columnStarted": "Início",
"columnProfile": "Perfil",
"columnDuration": "Duração",
"columnSites": "Sites",
"columnStatus": "Status",
"columnOperator": "Operador",
"empty": "Ainda sem execuções.",
"noMatch": "Nenhuma execução corresponde a este filtro.",
"unknownProfile": "Perfil desconhecido",
"outcome": "Motivo: {{reason}}",
"sitesVisited": "{{visited}}/{{total}}",
"sitesFailed_one": "Não foi possível acessar {{count}} site",
"sitesFailed_other": "Não foi possível acessar {{count}} sites",
"sitesFailed_many": "Não foi possível acessar {{count}} sites",
"consentHandled_one": "{{count}} aviso de consentimento tratado",
"consentHandled_other": "{{count}} avisos de consentimento tratados",
"consentHandled_many": "{{count}} avisos de consentimento tratados",
"sitesUnknown": "Sem dados"
},
"duration": {
"hm": "{{hours}} h {{minutes}} min",
"ms": "{{minutes}} min {{seconds}} s",
"s": "{{seconds}} s"
},
"picker": {
"title": "Inscrever perfis",
"description": "Escolha os perfis que o bot deve aquecer durante a noite.",
"searchPlaceholder": "Pesquisar perfis…",
"noProfiles": "Nenhum perfil corresponde.",
"alreadyEnrolled": "Inscrito",
"continue_one": "Continuar com {{count}}",
"continue_other": "Continuar com {{count}}",
"continue_many": "Continuar com {{count}}"
},
"enrol": {
"titleOne": "Inscrever {{name}}",
"titleCount_one": "Inscrever {{count}} perfil",
"titleCount_other": "Inscrever {{count}} perfis",
"titleCount_many": "Inscrever {{count}} perfis",
"editTitle": "Editar agenda",
"description": "O bot abre o perfil em uma máquina remota e navega pelos sites que você listar. Seu computador pode ficar desligado.",
"summaryNightly": "Executa toda noite às {{time}}, até {{minutes}} min por vez.",
"summaryWeeknights": "Executa de segunda a sexta às {{time}}, até {{minutes}} min por vez.",
"summaryAlternate": "Executa em noites alternadas às {{time}}, até {{minutes}} min por vez.",
"summaryCustom_one": "Executa {{count}} noite por semana às {{time}}, até {{minutes}} min por vez.",
"summaryCustom_other": "Executa {{count}} noites por semana às {{time}}, até {{minutes}} min por vez.",
"summaryCustom_many": "Executa {{count}} noites por semana às {{time}}, até {{minutes}} min por vez.",
"confirm": "Inscrever para hoje à noite",
"confirmSome": "Inscrever {{eligible}} de {{total}} hoje à noite",
"fixFirst": "Corrija isto primeiro",
"saving": "Inscrevendo…",
"saved": "Agenda salva",
"enrolled_one": "{{count}} perfil inscrito",
"enrolled_other": "{{count}} perfis inscritos",
"enrolled_many": "{{count}} perfis inscritos",
"adjust": "Ajustar a agenda",
"cadenceLabel": "Frequência",
"cadenceNightly": "Toda noite",
"cadenceWeeknights": "Noites de semana",
"cadenceAlternate": "Noites alternadas",
"cadenceCustom_one": "{{count}} noite por semana",
"cadenceCustom_other": "{{count}} noites por semana",
"cadenceCustom_many": "{{count}} noites por semana",
"timeLabel": "Horário de início",
"timeHint": "No fuso horário da impressão digital de cada perfil.",
"maxMinutesLabel": "Minutos máximos",
"intensityLabel": "Profundidade",
"sitesLabel": "Sites",
"sitesPlaceholder": "Um endereço por linha",
"sitesHint_one": "{{count}} site. Somente as páginas listadas aqui são visitadas.",
"sitesHint_other": "{{count}} sites. Somente as páginas listadas aqui são visitadas.",
"sitesHint_many": "{{count}} sites. Somente as páginas listadas aqui são visitadas.",
"sitesTooMany": "No máximo {{max}} sites.",
"presetsUnavailable": "Não foi possível carregar as predefinições de profundidade. Tente novamente em instantes.",
"confirmBulkTitle_one": "Inscrever {{count}} perfil no Cookie Bot?",
"confirmBulkTitle_other": "Inscrever {{count}} perfis no Cookie Bot?",
"confirmBulkTitle_many": "Inscrever {{count}} perfis no Cookie Bot?",
"confirmBulkDescription_one": "Reserva uma execução noturna nas suas horas remotas compartilhadas. Você escolhe o horário e os sites em seguida.",
"confirmBulkDescription_other": "Reservam {{count}} execuções noturnas nas suas horas remotas compartilhadas. Você escolhe o horário e os sites em seguida.",
"confirmBulkDescription_many": "Reservam {{count}} execuções noturnas nas suas horas remotas compartilhadas. Você escolhe o horário e os sites em seguida.",
"confirmBulkButton_one": "Continuar com {{count}} perfil",
"confirmBulkButton_other": "Continuar com {{count}} perfis",
"confirmBulkButton_many": "Continuar com {{count}} perfis",
"sitesRequired": "Adicione pelo menos um site.",
"addSitesFirst": "Adicione um site primeiro",
"presetsMissing": "Predefinições de profundidade indisponíveis"
},
"preset": {
"light": "Leve",
"balanced": "Padrão",
"deep": "Profunda"
},
"preflight": {
"ineligible_one": "{{count}} perfil não pode ser executado remotamente",
"ineligible_other": "{{count}} perfis não podem ser executados remotamente",
"ineligible_many": "{{count}} perfis não podem ser executados remotamente",
"reasonSync": "A sincronização está desativada",
"reasonEncrypted": "Criptografado de ponta a ponta",
"reasonNoFingerprint": "Sem sistema operacional registrado",
"reasonCrossOs": "Criado para {{os}}: nenhuma máquina remota corresponde",
"reasonNoExitNode": "Sem proxy nem VPN",
"fixSync": "Ativar a sincronização",
"fixEncrypted": "Abrir as configurações de sincronização",
"fixProxy": "Anexar um proxy",
"fixFailed": "Não foi possível aplicar essa correção.",
"exitNodeHint": "Sem proxy nem VPN, a execução sai pelo próprio endereço do data center da frota. Horas de tráfego vindo de uma rede de hospedagem danificam a identidade do perfil mais do que não aquecê-lo."
},
"conflict": {
"title": "{{email}} já aquece este perfil às {{time}}",
"detail": "Duas agendas no mesmo perfil gastam horas em dobro e podem colidir durante a execução.",
"keepTheirs": "Usar a agenda dessa pessoa",
"replace": "Substituir pela minha",
"replaceForbidden": "Somente o proprietário ou um administrador da equipe pode alterar a agenda de outra pessoa.",
"askThem": "Copiar {{email}}",
"emailCopied": "E-mail copiado",
"copyFailed": "Não foi possível copiar o endereço."
},
"actionBar": {
"enrol": "Inscrever no Cookie Bot",
"proRequired": "O Cookie Bot requer um plano Pro ou Team",
"noneEligible": "Nenhum dos perfis selecionados pode ser aquecido remotamente"
},
"actions": {
"enrol": "Inscrever no Cookie Bot",
"editSchedule": "Editar agenda",
"runNow": "Executar agora",
"runStarted": "Execução iniciada",
"viewActivity": "Ver a atividade",
"runNotStarted": "A execução não começou: {{reason}}"
},
"state": {
"enrolled": "Inscrito",
"notEnrolled": "Não inscrito",
"paused": "Pausado",
"summary": "{{cadence}} às {{time}}",
"rowMenu": "Opções do Cookie Bot para {{name}}",
"blocked": "Não pode executar: {{reason}}"
},
"team": {
"title": "Uso da equipe",
"pooled_one": "{{used}} de {{total}} h, {{count}} assento",
"pooled_other": "{{used}} de {{total}} h compartilhadas entre {{count}} assentos",
"pooled_many": "{{used}} de {{total}} h compartilhadas entre {{count}} assentos",
"hours": "{{hours}} h",
"legendBot": "Cookie Bot",
"legendInteractive": "Interativo",
"columnMember": "Membro",
"columnRuns": "Execuções",
"columnHours": "Horas",
"columnShare": "Parcela",
"noActivity": "Nenhuma sessão remota neste período.",
"soloNote": "Por enquanto, apenas o seu uso. Convide colegas para ver a divisão por membro.",
"runsFailed": "· {{n}} com falha"
},
"closeReason": {
"stoppedByUser": "Parado à mão",
"maxDuration": "Atingiu o limite de tempo",
"other": "Terminou: {{reason}}"
},
"outcome": {
"notEntitled": "Não está incluído no seu plano",
"syncDisabled": "A sincronização está desligada neste perfil",
"encryptedSync": "A sincronização com criptografia ponta a ponta não é suportada",
"proxyRequired": "Sem proxy nem VPN associado",
"touchFingerprint": "Impressões digitais de toque não são suportadas",
"platformUnsupported": "Nenhuma máquina executa o sistema deste perfil",
"noSites": "Nenhum site para visitar",
"quotaExhausted": "Horas remotas esgotadas",
"profileLocked": "O perfil estava aberto noutro lugar",
"noCapacity": "Nenhuma máquina estava livre",
"managerError": "A frota não conseguiu iniciar o navegador",
"budgetExceeded": "O tempo previsto para a noite acabou",
"cancelledByUser": "Parado à mão",
"unknown": "Motivo desconhecido ({{code}})"
}
}
}
+378 -7
View File
@@ -258,7 +258,8 @@
"emptyCreate": "Создать профиль",
"emptyImport": "Импортировать профили",
"emptyFilteredTitle": "Профили не найдены",
"emptyFilteredHint": "Нет профилей для этой группы или запроса. Попробуйте другой фильтр или создайте профиль."
"emptyFilteredHint": "Нет профилей для этой группы или запроса. Попробуйте другой фильтр или создайте профиль.",
"bot": "Бот"
},
"actions": {
"launch": "Запустить",
@@ -420,7 +421,9 @@
"editProxy": "Редактировать прокси",
"deleteProxy": "Удалить прокси",
"cannotDelete_one": "Невозможно удалить: используется {{count}} профилем",
"cannotDelete_few": "Невозможно удалить: используется {{count}} профилями",
"cannotDelete_other": "Невозможно удалить: используется {{count}} профилями",
"cannotDelete_many": "Невозможно удалить: используется {{count}} профилями",
"syncEnabled": "Синхронизация включена",
"syncDisabled": "Синхронизация отключена",
"updateSyncFailed": "Не удалось обновить синхронизацию",
@@ -813,7 +816,9 @@
"selectedCount": "Выбрано {{count}} cookie",
"selectedCount_plural": "Выбрано {{count}} cookie",
"dialogDescription_one": "Копировать cookies из исходного профиля в {{count}} выбранный профиль.",
"dialogDescription_few": "Копировать cookies из исходного профиля в {{count}} выбранных профиля.",
"dialogDescription_other": "Копировать cookies из исходного профиля в {{count}} выбранных профилей.",
"dialogDescription_many": "Копировать cookies из исходного профиля в {{count}} выбранных профилей.",
"sourceProfile": "Исходный профиль",
"sourcePlaceholder": "Выберите профиль для копирования cookies",
"running": "(запущен)",
@@ -831,7 +836,9 @@
"successMessage": "Скопировано {{copied}} cookies ({{replaced}} заменено)",
"failedMessage": "Не удалось скопировать cookies: {{error}}",
"copyButton_one": "Скопировать {{count}} cookie",
"copyButton_few": "Скопировать {{count}} cookies",
"copyButton_other": "Скопировать {{count}} cookies",
"copyButton_many": "Скопировать {{count}} cookies",
"copyButtonEmpty": "Скопировать cookies"
},
"success": "Cookie успешно скопированы",
@@ -1402,7 +1409,9 @@
"editVpn": "Редактировать VPN",
"deleteVpn": "Удалить VPN",
"cannotDelete_one": "Невозможно удалить: используется {{count}} профилем",
"cannotDelete_few": "Невозможно удалить: используется {{count}} профилями",
"cannotDelete_other": "Невозможно удалить: используется {{count}} профилями",
"cannotDelete_many": "Невозможно удалить: используется {{count}} профилями",
"syncCannotDisable": "Нельзя отключить синхронизацию, пока этот VPN используется синхронизированными профилями",
"deleteSuccess": "VPN успешно удален",
"deleteFailed": "Не удалось удалить VPN",
@@ -1480,7 +1489,9 @@
"noGroups": "Групп еще нет. Создайте первую группу, используя кнопку выше.",
"loading": "Загрузка групп...",
"profileCount_one": "{{count}} профиль",
"profileCount_few": "{{count}} профиля",
"profileCount_other": "{{count}} профилей",
"profileCount_many": "{{count}} профилей",
"groupsLabel": "Группы",
"profilesCol": "Профили",
"syncCannotDisable": "Нельзя отключить синхронизацию, пока эта группа используется синхронизированными профилями",
@@ -1497,7 +1508,9 @@
"proxyAssignment": {
"title": "Назначить прокси / VPN",
"description_one": "Назначить прокси или VPN для {{count}} выбранного профиля.",
"description_few": "Назначить прокси или VPN для {{count}} выбранных профилей.",
"description_other": "Назначить прокси или VPN для {{count}} выбранных профилей.",
"description_many": "Назначить прокси или VPN для {{count}} выбранных профилей.",
"selectLabel": "Прокси / VPN",
"placeholder": "Выберите прокси или VPN",
"noProxy": "Без прокси / VPN",
@@ -1516,7 +1529,9 @@
"groupAssignment": {
"title": "Назначить группу",
"description_one": "Назначить группу для {{count}} выбранного профиля.",
"description_few": "Назначить группу для {{count}} выбранных профилей.",
"description_other": "Назначить группу для {{count}} выбранных профилей.",
"description_many": "Назначить группу для {{count}} выбранных профилей.",
"selectLabel": "Группа",
"placeholder": "Выберите группу",
"noGroup": "Без группы (по умолчанию)",
@@ -1851,7 +1866,35 @@
"vlessConfigInvalid": "URI VLESS недействителен.",
"xrayUnavailable": "Xray-core недоступен в этой системе.",
"xrayUnsupportedOs": "Для VLESS требуется macOS 12 или новее.",
"xrayStartFailed": "Не удалось запустить Xray-core."
"xrayStartFailed": "Не удалось запустить Xray-core.",
"cloudNotSignedIn": "Войдите в аккаунт Donut Browser, чтобы использовать эту функцию.",
"cloudUnreachable": "Не удалось связаться с серверами Donut Browser. Проверьте подключение и попробуйте снова.",
"cloudRequestFailed": "Запрос не удался. Попробуйте ещё раз через минуту.",
"remoteRateLimited": "Слишком много запросов. Подождите немного и попробуйте снова.",
"remoteNoCapacity": "Сейчас нет свободных удалённых машин. Попробуйте через несколько минут.",
"remoteNotEntitled": "Ваш тариф не включает удалённый запуск.",
"remoteSessionRefused": "Удалённая машина отклонила эту сессию.",
"remoteSessionNotFound": "Этой удалённой сессии больше не существует.",
"remoteSessionConflict": "Этот профиль уже открыт в другом месте.",
"remoteSyncInProgress": "Профиль ещё загружается в облачную синхронизацию. Дождитесь окончания и попробуйте снова.",
"remoteHoursExhausted": "Вы израсходовали все {{used}} из {{granted}} удалённых часов в этом месяце.",
"notTeamMember": "Вы не состоите в команде.",
"cookieBotNotEntitled": "Ваш тариф не включает Cookie Bot.",
"cookieBotNotEnrolled": "Этот профиль ещё не настроен для Cookie Bot.",
"cookieBotScheduleConflict": "{{email}} уже прогревает этот профиль в {{time}}.",
"cookieBotRunInProgress": "Для этого профиля уже выполняется запуск.",
"cookieBotRunNotFound": "Этого запуска больше не существует.",
"cookieBotInvalidSchedule": "Это расписание некорректно. Проверьте время, дни и длительность.",
"cookieBotInvalidTimezone": "{{timezone}} — часовой пояс, который сервер не распознаёт.",
"cookieBotInvalidPeriod": "Некорректный период. Укажите месяц, например 2026-08.",
"cookieBotSiteLimit": "Укажите от {{min}} до {{max}} сайтов, каждый — полным адресом http или https.",
"cookieBotRequiresCloudSync": "Сначала включите облачную синхронизацию для этого профиля: иначе удалённая машина не сможет его получить.",
"cookieBotEncryptedSyncUnsupported": "Этот профиль использует сквозное шифрование синхронизации, которое удалённая машина не может расшифровать. Переключите его на обычную синхронизацию.",
"cookieBotUnknownPlatform": "Для этого профиля не записана операционная система, поэтому подобрать машину невозможно.",
"cookieBotUnsupportedPlatform": "Cookie Bot не может запускать профили {{platform}}. Поддерживаются только профили Windows и macOS.",
"cookieBotRequiresExitNode": "Сначала назначьте прокси или VPN. Без них трафик пойдёт с адреса дата-центра, а это вредит репутации профиля.",
"unknownCode": "Что-то пошло не так: {{code}}",
"cookieBotTouchFingerprintUnsupported": "Этот профиль выдаёт себя за сенсорное устройство, которым бот управлять не может. Используйте настольный отпечаток."
},
"rail": {
"profiles": "Профили",
@@ -1870,7 +1913,9 @@
},
"network": "Сеть",
"integrations": "Интеграции",
"account": "Аккаунт"
"account": "Аккаунт",
"cookieBot": "Cookie Bot",
"cookieBotRunning": "Cookie Bot — сейчас работает"
},
"pageTitle": {
"proxies": "Сеть",
@@ -1881,7 +1926,8 @@
"integrations": "Интеграции",
"account": "Аккаунт",
"import": "Импорт профиля",
"shortcuts": "Сочетания клавиш"
"shortcuts": "Сочетания клавиш",
"cookieBot": "Cookie Bot"
},
"encryption": {
"required": {
@@ -1924,7 +1970,8 @@
},
"tabs": {
"account": "Аккаунт",
"selfHosted": "Свой сервер"
"selfHosted": "Свой сервер",
"teamUsage": "Использование командой"
},
"selfHosted": {
"title": "Свой сервер синхронизации",
@@ -1937,7 +1984,8 @@
},
"deviceOrdinal": "{{ordinal}} из {{count}}",
"automationPrimaryOnly": "Автоматизация браузера работает только на вашем основном устройстве (Устройство 1). Выйдите из аккаунта на нём, чтобы использовать её здесь.",
"automationActiveHere": "Автоматизация браузера активна на этом устройстве."
"automationActiveHere": "Автоматизация браузера активна на этом устройстве.",
"viewTeamUsage": "Посмотреть использование командой"
},
"shortcutsPage": {
"title": "Сочетания клавиш",
@@ -1970,7 +2018,8 @@
"goGroups": "Перейти к Группам",
"goIntegrations": "Перейти к Интеграциям",
"goAccount": "Перейти к Аккаунту",
"goSettings": "Перейти к Настройкам"
"goSettings": "Перейти к Настройкам",
"goCookieBot": "Cookie Bot"
},
"closeConfirm": {
"title": "Закрыть Donut Browser?",
@@ -2102,5 +2151,327 @@
"matchToProxy": "Подогнать отпечаток под прокси",
"matching": "Подгонка…",
"matchSuccess": "Отпечаток обновлён под прокси. Перезапустите профиль, чтобы применить."
},
"cookieBot": {
"title": "Cookie Bot",
"description": "Ночной прогрев профилей на удалённой машине.",
"tabs": {
"overview": "Обзор",
"schedule": "Расписание",
"activity": "Активность",
"team": "Команда"
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен. Требуется тариф Pro или Team."
},
"empty": {
"title": "Нет подключённых профилей",
"hint": "Выберите профиль, и бот будет прогревать его ночью на удалённой машине. Ваш компьютер может быть выключен.",
"cta": "Подключить профиль"
},
"tonight": {
"label": "Сегодня ночью",
"nextRun": "Следующий запуск в {{time}}",
"dueCount_one": "{{count}} профиль в очереди",
"dueCount_few": "{{count}} профиля в очереди",
"dueCount_other": "{{count}} профилей в очереди",
"dueCount_many": "{{count}} профилей в очереди",
"nothingScheduled": "Ничего не запланировано"
},
"lastDay": {
"label": "Последние 24 часа",
"none": "Запусков пока нет",
"ran_one": "{{count}} выполнен",
"ran_few": "{{count}} выполнено",
"ran_other": "{{count}} выполнено",
"ran_many": "{{count}} выполнено",
"partial_one": "{{count}} частичный",
"partial_few": "{{count}} частичных",
"partial_other": "{{count}} частичных",
"partial_many": "{{count}} частичных",
"failed_one": "{{count}} с ошибкой",
"failed_few": "{{count}} с ошибками",
"failed_other": "{{count}} с ошибками",
"failed_many": "{{count}} с ошибками"
},
"chart": {
"machineTime": "Машинное время за ночь",
"minutes": "{{minutes}} мин"
},
"hours": {
"label": "Удалённые часы",
"remaining": "Осталось {{remaining}} ч из {{total}}",
"remainingOf": "из {{total}} ч",
"used": "Использовано {{used}} из {{total}} ч",
"resets": "Обновление {{date}}",
"exhausted": "Удалённые часы закончились. Расписания остаются подключёнными и возобновятся в следующем цикле.",
"exhaustedOn": "Удалённые часы закончились. Расписания остаются подключёнными и возобновятся {{date}}.",
"estimate": "Около {{hours}} ч в неделю · осталось {{remaining}} ч в этом цикле",
"estimateOverBudget": "Нужно около {{hours}} ч в неделю — осталось всего {{remaining}} ч",
"estimateOnly": "Около {{hours}} ч в неделю"
},
"enrolled": {
"columnProfile": "Профиль",
"columnCadence": "Периодичность",
"columnTime": "Время",
"columnNextRun": "Следующий запуск",
"columnLastRun": "Последний запуск",
"enrolProfiles": "Подключить профили",
"edit": "Изменить расписание",
"neverRun": "Никогда",
"pausedNoHours": "Приостановлено — часы закончились",
"profileMissing": "Этого профиля нет на этом компьютере."
},
"schedule": {
"empty": "Пока ничего не запланировано.",
"quietHours_one": "{{count}} свободный час",
"quietHours_few": "{{count}} свободных часа",
"quietHours_other": "{{count}} свободных часов",
"quietHours_many": "{{count}} свободных часов",
"crowded_one": "{{count}} профиль стартует одновременно",
"crowded_few": "{{count}} профиля стартуют одновременно",
"crowded_other": "{{count}} профилей стартуют одновременно",
"crowded_many": "{{count}} профилей стартуют одновременно",
"unenrol": "Убрать из Cookie Bot",
"unenrolTitle": "Убрать {{name}} из Cookie Bot?",
"unenrolDescription": "Прогрев прекратится уже этой ночью. Прошлые запуски останутся во вкладке «Активность».",
"unenrolled": "Убрано из Cookie Bot"
},
"status": {
"provisioning": "Готовим машину",
"ready": "Загружаем профиль",
"warming": "Прогрев",
"finished": "Завершение",
"failed": "Сбой"
},
"runStatus": {
"pending": "В очереди",
"running": "Выполняется",
"succeeded": "Завершён",
"partial": "Частично",
"failed": "Ошибка",
"skipped": "Пропущен",
"cancelled": "Остановлен"
},
"running": {
"label": "Сейчас выполняется",
"more": "+{{count}} ещё",
"stop": "Остановить запуск",
"stopped": "Запуск остановлен"
},
"live": {
"idle": "Сейчас ничего не выполняется",
"streamOffline": "Обновления в реальном времени недоступны",
"streamOfflineDetail": "Обновления в реальном времени недоступны — переподключаемся. Данные ниже могут быть устаревшими.",
"unnamedSession": "Удалённая сессия",
"notStartedYet": "Машина ещё не сообщила время начала.",
"sitesProgress": "{{visited}} из {{total}} сайтов",
"sitesUnknown": "Сайты ещё не переданы",
"consentHandled_one": "Обработан {{count}} баннер согласия",
"consentHandled_few": "Обработано {{count}} баннера согласия",
"consentHandled_other": "Обработано {{count}} баннеров согласия",
"consentHandled_many": "Обработано {{count}} баннеров согласия",
"consentUnknown": "Баннеры согласия ещё не переданы",
"billed": "Начислено {{duration}}",
"billedUnknown": "Оплачиваемое время ещё не передано",
"chunk": "Часть {{index}} из {{total}}"
},
"history": {
"title": "Запуски",
"allProfiles": "Все профили",
"searchPlaceholder": "Поиск профилей…",
"filterAll": "Все запуски",
"filterComplete": "Завершённые",
"filterPartial": "Частичные",
"filterFailed": "С ошибкой",
"columnStarted": "Начало",
"columnProfile": "Профиль",
"columnDuration": "Длительность",
"columnSites": "Сайты",
"columnStatus": "Статус",
"columnOperator": "Оператор",
"empty": "Запусков пока нет.",
"noMatch": "Нет запусков по этому фильтру.",
"unknownProfile": "Неизвестный профиль",
"outcome": "Причина: {{reason}}",
"sitesVisited": "{{visited}}/{{total}}",
"sitesFailed_one": "{{count}} сайт недоступен",
"sitesFailed_few": "{{count}} сайта недоступно",
"sitesFailed_other": "{{count}} сайтов недоступно",
"sitesFailed_many": "{{count}} сайтов недоступно",
"consentHandled_one": "Обработан {{count}} баннер согласия",
"consentHandled_few": "Обработано {{count}} баннера согласия",
"consentHandled_other": "Обработано {{count}} баннеров согласия",
"consentHandled_many": "Обработано {{count}} баннеров согласия",
"sitesUnknown": "Нет данных"
},
"duration": {
"hm": "{{hours}} ч {{minutes}} мин",
"ms": "{{minutes}} мин {{seconds}} с",
"s": "{{seconds}} с"
},
"picker": {
"title": "Подключить профили",
"description": "Выберите профили, которые бот будет прогревать ночью.",
"searchPlaceholder": "Поиск профилей…",
"noProfiles": "Подходящих профилей нет.",
"alreadyEnrolled": "Подключён",
"continue_one": "Продолжить с {{count}}",
"continue_few": "Продолжить с {{count}}",
"continue_other": "Продолжить с {{count}}",
"continue_many": "Продолжить с {{count}}"
},
"enrol": {
"titleOne": "Подключить {{name}}",
"titleCount_one": "Подключить {{count}} профиль",
"titleCount_few": "Подключить {{count}} профиля",
"titleCount_other": "Подключить {{count}} профилей",
"titleCount_many": "Подключить {{count}} профилей",
"editTitle": "Изменить расписание",
"description": "Бот открывает профиль на удалённой машине и просматривает указанные вами сайты. Ваш компьютер может быть выключен.",
"summaryNightly": "Запускается каждую ночь в {{time}}, не дольше {{minutes}} мин за раз.",
"summaryWeeknights": "Запускается с понедельника по пятницу в {{time}}, не дольше {{minutes}} мин за раз.",
"summaryAlternate": "Запускается через ночь в {{time}}, не дольше {{minutes}} мин за раз.",
"summaryCustom_one": "Запускается {{count}} ночь в неделю в {{time}}, не дольше {{minutes}} мин за раз.",
"summaryCustom_few": "Запускается {{count}} ночи в неделю в {{time}}, не дольше {{minutes}} мин за раз.",
"summaryCustom_other": "Запускается {{count}} ночей в неделю в {{time}}, не дольше {{minutes}} мин за раз.",
"summaryCustom_many": "Запускается {{count}} ночей в неделю в {{time}}, не дольше {{minutes}} мин за раз.",
"confirm": "Подключить на эту ночь",
"confirmSome": "Подключить {{eligible}} из {{total}} на эту ночь",
"fixFirst": "Сначала исправьте это",
"saving": "Подключаем…",
"saved": "Расписание сохранено",
"enrolled_one": "Подключён {{count}} профиль",
"enrolled_few": "Подключено {{count}} профиля",
"enrolled_other": "Подключено {{count}} профилей",
"enrolled_many": "Подключено {{count}} профилей",
"adjust": "Настроить расписание",
"cadenceLabel": "Периодичность",
"cadenceNightly": "Каждую ночь",
"cadenceWeeknights": "По будним ночам",
"cadenceAlternate": "Через ночь",
"cadenceCustom_one": "{{count}} ночь в неделю",
"cadenceCustom_few": "{{count}} ночи в неделю",
"cadenceCustom_other": "{{count}} ночей в неделю",
"cadenceCustom_many": "{{count}} ночей в неделю",
"timeLabel": "Время начала",
"timeHint": "По часовому поясу отпечатка каждого профиля.",
"maxMinutesLabel": "Максимум минут",
"intensityLabel": "Глубина",
"sitesLabel": "Сайты",
"sitesPlaceholder": "По одному адресу в строке",
"sitesHint_one": "{{count}} сайт. Посещаются только указанные здесь страницы.",
"sitesHint_few": "{{count}} сайта. Посещаются только указанные здесь страницы.",
"sitesHint_other": "{{count}} сайтов. Посещаются только указанные здесь страницы.",
"sitesHint_many": "{{count}} сайтов. Посещаются только указанные здесь страницы.",
"sitesTooMany": "Не более {{max}} сайтов.",
"presetsUnavailable": "Не удалось загрузить пресеты глубины. Попробуйте ещё раз через минуту.",
"confirmBulkTitle_one": "Подключить {{count}} профиль к Cookie Bot?",
"confirmBulkTitle_few": "Подключить {{count}} профиля к Cookie Bot?",
"confirmBulkTitle_other": "Подключить {{count}} профилей к Cookie Bot?",
"confirmBulkTitle_many": "Подключить {{count}} профилей к Cookie Bot?",
"confirmBulkDescription_one": "Это резервирует ночной запуск за счёт общих удалённых часов. Время и сайты вы выберете дальше.",
"confirmBulkDescription_few": "Это резервирует {{count}} ночных запуска за счёт общих удалённых часов. Время и сайты вы выберете дальше.",
"confirmBulkDescription_other": "Это резервирует {{count}} ночных запусков за счёт общих удалённых часов. Время и сайты вы выберете дальше.",
"confirmBulkDescription_many": "Это резервирует {{count}} ночных запусков за счёт общих удалённых часов. Время и сайты вы выберете дальше.",
"confirmBulkButton_one": "Продолжить с {{count}} профилем",
"confirmBulkButton_few": "Продолжить с {{count}} профилями",
"confirmBulkButton_other": "Продолжить с {{count}} профилями",
"confirmBulkButton_many": "Продолжить с {{count}} профилями",
"sitesRequired": "Добавьте хотя бы один сайт.",
"addSitesFirst": "Сначала добавьте сайт",
"presetsMissing": "Пресеты глубины недоступны"
},
"preset": {
"light": "Лёгкая",
"balanced": "Стандартная",
"deep": "Глубокая"
},
"preflight": {
"ineligible_one": "{{count}} профиль нельзя запустить удалённо",
"ineligible_few": "{{count}} профиля нельзя запустить удалённо",
"ineligible_other": "{{count}} профилей нельзя запустить удалённо",
"ineligible_many": "{{count}} профилей нельзя запустить удалённо",
"reasonSync": "Синхронизация отключена",
"reasonEncrypted": "Сквозное шифрование",
"reasonNoFingerprint": "Операционная система не записана",
"reasonCrossOs": "Создан для {{os}} — подходящей удалённой машины нет",
"reasonNoExitNode": "Нет прокси или VPN",
"fixSync": "Включить синхронизацию",
"fixEncrypted": "Открыть настройки синхронизации",
"fixProxy": "Назначить прокси",
"fixFailed": "Не удалось применить это исправление.",
"exitNodeHint": "Без прокси или VPN трафик выходит с собственного адреса дата-центра нашего парка машин. Часы трафика из хостинговой сети вредят репутации профиля сильнее, чем полный отказ от прогрева."
},
"conflict": {
"title": "{{email}} уже прогревает этот профиль в {{time}}",
"detail": "Два расписания на одном профиле расходуют часы дважды и могут столкнуться прямо во время запуска.",
"keepTheirs": "Оставить их расписание",
"replace": "Заменить моим",
"replaceForbidden": "Менять чужое расписание может только владелец команды или администратор.",
"askThem": "Скопировать {{email}}",
"emailCopied": "Адрес скопирован",
"copyFailed": "Не удалось скопировать адрес."
},
"actionBar": {
"enrol": "Подключить к Cookie Bot",
"proRequired": "Для Cookie Bot нужен тариф Pro или Team",
"noneEligible": "Ни один из выбранных профилей нельзя прогреть удалённо"
},
"actions": {
"enrol": "Подключить к Cookie Bot",
"editSchedule": "Изменить расписание",
"runNow": "Запустить сейчас",
"runStarted": "Запуск начат",
"viewActivity": "Посмотреть активность",
"runNotStarted": "Запуск не начался: {{reason}}"
},
"state": {
"enrolled": "Подключён",
"notEnrolled": "Не подключён",
"paused": "Приостановлен",
"summary": "{{cadence}} в {{time}}",
"rowMenu": "Параметры Cookie Bot для {{name}}",
"blocked": "Не может запуститься: {{reason}}"
},
"team": {
"title": "Использование командой",
"pooled_one": "{{used}} из {{total}} ч, {{count}} место",
"pooled_few": "{{used}} из {{total}} ч на {{count}} места",
"pooled_other": "{{used}} из {{total}} ч на {{count}} мест",
"pooled_many": "{{used}} из {{total}} ч на {{count}} мест",
"hours": "{{hours}} ч",
"legendBot": "Cookie Bot",
"legendInteractive": "Интерактивно",
"columnMember": "Участник",
"columnRuns": "Запуски",
"columnHours": "Часы",
"columnShare": "Доля",
"noActivity": "За этот период удалённых сессий нет.",
"soloNote": "Пока показано только ваше использование. Пригласите коллег, чтобы увидеть распределение по участникам.",
"runsFailed": с ошибкой: {{n}}"
},
"closeReason": {
"stoppedByUser": "Остановлено вручную",
"maxDuration": "Достигнут предел времени",
"other": "Завершено: {{reason}}"
},
"outcome": {
"notEntitled": "Не входит в ваш тариф",
"syncDisabled": "Для этого профиля отключена облачная синхронизация",
"encryptedSync": "Сквозное шифрование синхронизации не поддерживается",
"proxyRequired": "Не назначены ни прокси, ни VPN",
"touchFingerprint": "Сенсорные отпечатки не поддерживаются",
"platformUnsupported": "Нет машины с системой этого профиля",
"noSites": "Нет сайтов для посещения",
"quotaExhausted": "Удалённые часы израсходованы",
"profileLocked": "Профиль был открыт в другом месте",
"noCapacity": "Свободных машин не было",
"managerError": "Флот не смог запустить браузер",
"budgetExceeded": "Отведённое на ночь время закончилось",
"cancelledByUser": "Остановлено вручную",
"unknown": "Неизвестная причина ({{code}})"
}
}
}
+324 -7
View File
@@ -258,7 +258,8 @@
"emptyCreate": "Profil oluştur",
"emptyImport": "Profilleri içe aktar",
"emptyFilteredTitle": "Profil bulunamadı",
"emptyFilteredHint": "Bu grup veya aramayla eşleşen profil yok. Başka bir filtre deneyin veya yeni bir profil oluşturun."
"emptyFilteredHint": "Bu grup veya aramayla eşleşen profil yok. Başka bir filtre deneyin veya yeni bir profil oluşturun.",
"bot": "Bot"
},
"actions": {
"launch": "Başlat",
@@ -1851,7 +1852,35 @@
"vlessConfigInvalid": "VLESS URI'si geçersiz.",
"xrayUnavailable": "Xray-core bu sistemde kullanılamıyor.",
"xrayUnsupportedOs": "VLESS için macOS 12 veya sonraki bir sürüm gerekir.",
"xrayStartFailed": "Xray-core başlatılamadı."
"xrayStartFailed": "Xray-core başlatılamadı.",
"cloudNotSignedIn": "Bunu kullanmak için Donut Browser hesabınıza giriş yapın.",
"cloudUnreachable": "Donut Browser sunucularına ulaşılamadı. Bağlantınızı kontrol edip tekrar deneyin.",
"cloudRequestFailed": "İstek başarısız oldu. Birazdan tekrar deneyin.",
"remoteRateLimited": "Çok fazla istek. Biraz bekleyip tekrar deneyin.",
"remoteNoCapacity": "Şu anda boş uzak makine yok. Birkaç dakika sonra tekrar deneyin.",
"remoteNotEntitled": "Planınız uzaktan çalıştırmayı içermiyor.",
"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.",
"remoteSyncInProgress": "Bu profil hâlâ buluta yükleniyor. Eşitleme bitince tekrar deneyin.",
"remoteHoursExhausted": "Bu ayki {{granted}} uzak saatinizin {{used}} saatinin tamamını kullandınız.",
"notTeamMember": "Herhangi bir ekibin üyesi değilsiniz.",
"cookieBotNotEntitled": "Planınız Cookie Bot'u içermiyor.",
"cookieBotNotEnrolled": "Bu profil henüz Cookie Bot için ayarlanmadı.",
"cookieBotScheduleConflict": "{{email}} bu profili zaten {{time}} saatinde ısıtıyor.",
"cookieBotRunInProgress": "Bu profil için zaten devam eden bir çalışma var.",
"cookieBotRunNotFound": "Bu çalışma artık mevcut değil.",
"cookieBotInvalidSchedule": "Bu zamanlama geçerli değil. Çalışma saatini, günleri ve süreyi kontrol edin.",
"cookieBotInvalidTimezone": "{{timezone}}, sunucunun tanıdığı bir saat dilimi değil.",
"cookieBotInvalidPeriod": "Bu dönem geçerli değil. 2026-08 gibi bir ay kullanın.",
"cookieBotSiteLimit": "En az {{min}}, en çok {{max}} site girin; her biri tam http veya https adresi olsun.",
"cookieBotRequiresCloudSync": "Önce bu profil için bulut eşitlemeyi açın: uzak makinenin profili almasının başka yolu yok.",
"cookieBotEncryptedSyncUnsupported": "Bu profil, uzak makinenin çözemeyeceği uçtan uca şifreli eşitleme kullanıyor. Normal eşitlemeye geçirin.",
"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.",
"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."
},
"rail": {
"profiles": "Profiller",
@@ -1870,7 +1899,9 @@
},
"network": "Ağ",
"integrations": "Entegrasyonlar",
"account": "Hesap"
"account": "Hesap",
"cookieBot": "Cookie Bot",
"cookieBotRunning": "Cookie Bot — şu anda çalışıyor"
},
"pageTitle": {
"proxies": "Ağ",
@@ -1881,7 +1912,8 @@
"integrations": "Entegrasyonlar",
"account": "Hesap",
"import": "Profil içe aktar",
"shortcuts": "Klavye kısayolları"
"shortcuts": "Klavye kısayolları",
"cookieBot": "Cookie Bot"
},
"encryption": {
"required": {
@@ -1924,7 +1956,8 @@
},
"tabs": {
"account": "Hesap",
"selfHosted": "Kendi sunucunuz"
"selfHosted": "Kendi sunucunuz",
"teamUsage": "Ekip kullanımı"
},
"selfHosted": {
"title": "Kendi eşitleme sunucunuz",
@@ -1937,7 +1970,8 @@
},
"deviceOrdinal": "{{ordinal}} / {{count}}",
"automationPrimaryOnly": "Tarayıcı otomasyonu yalnızca birincil cihazınızda (Cihaz 1) çalışır. Burada kullanmak için oradaki oturumu kapatın.",
"automationActiveHere": "Tarayıcı otomasyonu bu cihazda etkin."
"automationActiveHere": "Tarayıcı otomasyonu bu cihazda etkin.",
"viewTeamUsage": "Ekip kullanımını görüntüle"
},
"shortcutsPage": {
"title": "Klavye kısayolları",
@@ -1970,7 +2004,8 @@
"goGroups": "Gruplar'a git",
"goIntegrations": "Entegrasyonlar'a git",
"goAccount": "Hesap'a git",
"goSettings": "Ayarlar'a git"
"goSettings": "Ayarlar'a git",
"goCookieBot": "Cookie Bot"
},
"closeConfirm": {
"title": "Donut Browser kapatılsın mı?",
@@ -2102,5 +2137,287 @@
"matchToProxy": "Parmak izini proxy'ye eşle",
"matching": "Eşleniyor…",
"matchSuccess": "Parmak izi proxy'ye uyacak şekilde güncellendi. Uygulamak için profili yeniden başlatın."
},
"cookieBot": {
"title": "Cookie Bot",
"description": "Uzak bir makinede gece boyunca profil ısıtma.",
"tabs": {
"overview": "Genel bakış",
"schedule": "Zamanlama",
"activity": "Etkinlik",
"team": "Ekip"
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot, profillerinizi gece boyunca uzak bir makinede ısıtır; böylece bilgisayarınız açık olmadan çerezlerini ve geçmişlerini korurlar. Pro veya Team planı gerekir."
},
"empty": {
"title": "Kayıtlı profil yok",
"hint": "Bir profil seçin, bot onu gece boyunca uzak bir makinede ısıtsın. Bilgisayarınız kapalı olabilir.",
"cta": "Profil kaydet"
},
"tonight": {
"label": "Bu gece",
"nextRun": "Sonraki çalışma {{time}}",
"dueCount_one": "{{count}} profil sırada",
"dueCount_other": "{{count}} profil sırada",
"nothingScheduled": "Zamanlanmış bir şey yok"
},
"lastDay": {
"label": "Son 24 saat",
"none": "Henüz çalışma yok",
"ran_one": "{{count}} çalıştı",
"ran_other": "{{count}} çalıştı",
"partial_one": "{{count}} kısmi",
"partial_other": "{{count}} kısmi",
"failed_one": "{{count}} başarısız",
"failed_other": "{{count}} başarısız"
},
"chart": {
"machineTime": "Gece başına makine süresi",
"minutes": "{{minutes}} dk"
},
"hours": {
"label": "Uzak saatler",
"remaining": "{{total}} saatin {{remaining}} saati kaldı",
"remainingOf": "/ {{total}} sa",
"used": "{{total}} saatin {{used}} saati kullanıldı",
"resets": "{{date}} tarihinde sıfırlanır",
"exhausted": "Uzak saat kalmadı. Zamanlamalar kayıtlı kalır ve sonraki döngüde devam eder.",
"exhaustedOn": "Uzak saat kalmadı. Zamanlamalar kayıtlı kalır ve {{date}} tarihinde devam eder.",
"estimate": "Haftada yaklaşık {{hours}} sa · bu döngüde {{remaining}} sa kaldı",
"estimateOverBudget": "Haftada yaklaşık {{hours}} sa gerekiyor — yalnızca {{remaining}} sa kaldı",
"estimateOnly": "Haftada yaklaşık {{hours}} sa"
},
"enrolled": {
"columnProfile": "Profil",
"columnCadence": "Sıklık",
"columnTime": "Saat",
"columnNextRun": "Sonraki çalışma",
"columnLastRun": "Son çalışma",
"enrolProfiles": "Profilleri kaydet",
"edit": "Zamanlamayı düzenle",
"neverRun": "Hiç",
"pausedNoHours": "Duraklatıldı — saat kalmadı",
"profileMissing": "Bu profil bu bilgisayarda değil."
},
"schedule": {
"empty": "Henüz hiçbir şey zamanlanmadı.",
"quietHours_one": "{{count}} boş saat",
"quietHours_other": "{{count}} boş saat",
"crowded_one": "{{count}} profil aynı anda başlıyor",
"crowded_other": "{{count}} profil aynı anda başlıyor",
"unenrol": "Cookie Bot'tan çıkar",
"unenrolTitle": "{{name}} Cookie Bot'tan çıkarılsın mı?",
"unenrolDescription": "Bu gece ısıtma duracak. Geçmiş çalışmalar Etkinlik sekmesinde kalır.",
"unenrolled": "Cookie Bot'tan çıkarıldı"
},
"status": {
"provisioning": "Makine hazırlanıyor",
"ready": "Profil yükleniyor",
"warming": "Isıtılıyor",
"finished": "Tamamlanıyor",
"failed": "Başarısız"
},
"runStatus": {
"pending": "Sırada",
"running": "Çalışıyor",
"succeeded": "Tamamlandı",
"partial": "Kısmi",
"failed": "Başarısız",
"skipped": "Atlandı",
"cancelled": "Durduruldu"
},
"running": {
"label": "Şu anda çalışıyor",
"more": "+{{count}} daha",
"stop": "Çalışmayı durdur",
"stopped": "Çalışma durduruldu"
},
"live": {
"idle": "Şu anda çalışan bir şey yok",
"streamOffline": "Canlı güncellemeler çevrimdışı",
"streamOfflineDetail": "Canlı güncellemeler çevrimdışı — yeniden bağlanılıyor. Aşağıdaki çalışmalar güncel olmayabilir.",
"unnamedSession": "Uzak oturum",
"notStartedYet": "Makine henüz bir başlangıç saati bildirmedi.",
"sitesProgress": "{{total}} sitenin {{visited}} tanesi",
"sitesUnknown": "Siteler henüz bildirilmedi",
"consentHandled_one": "{{count}} çerez izni istemi işlendi",
"consentHandled_other": "{{count}} çerez izni istemi işlendi",
"consentUnknown": "Çerez izni istemleri henüz bildirilmedi",
"billed": "{{duration}} faturalandırıldı",
"billedUnknown": "Faturalandırılan süre henüz bildirilmedi",
"chunk": "{{total}} bölümden {{index}}."
},
"history": {
"title": "Çalışmalar",
"allProfiles": "Tüm profiller",
"searchPlaceholder": "Profillerde ara…",
"filterAll": "Tüm çalışmalar",
"filterComplete": "Tamamlanan",
"filterPartial": "Kısmi",
"filterFailed": "Başarısız",
"columnStarted": "Başlangıç",
"columnProfile": "Profil",
"columnDuration": "Süre",
"columnSites": "Siteler",
"columnStatus": "Durum",
"columnOperator": "Operatör",
"empty": "Henüz çalışma yok.",
"noMatch": "Bu filtreye uyan çalışma yok.",
"unknownProfile": "Bilinmeyen profil",
"outcome": "Neden: {{reason}}",
"sitesVisited": "{{visited}}/{{total}}",
"sitesFailed_one": "{{count}} siteye ulaşılamadı",
"sitesFailed_other": "{{count}} siteye ulaşılamadı",
"consentHandled_one": "{{count}} çerez izni istemi işlendi",
"consentHandled_other": "{{count}} çerez izni istemi işlendi",
"sitesUnknown": "Bildirilmedi"
},
"duration": {
"hm": "{{hours}} sa {{minutes}} dk",
"ms": "{{minutes}} dk {{seconds}} sn",
"s": "{{seconds}} sn"
},
"picker": {
"title": "Profilleri kaydet",
"description": "Botun gece boyunca ısıtacağı profilleri seçin.",
"searchPlaceholder": "Profillerde ara…",
"noProfiles": "Eşleşen profil yok.",
"alreadyEnrolled": "Kayıtlı",
"continue_one": "{{count}} ile devam et",
"continue_other": "{{count}} ile devam et"
},
"enrol": {
"titleOne": "{{name}} profilini kaydet",
"titleCount_one": "{{count}} profili kaydet",
"titleCount_other": "{{count}} profili kaydet",
"editTitle": "Zamanlamayı düzenle",
"description": "Bot, profili uzak bir makinede açar ve listelediğiniz siteleri gezer. Bilgisayarınız kapalı olabilir.",
"summaryNightly": "Her gece {{time}} saatinde, her seferinde en fazla {{minutes}} dk çalışır.",
"summaryWeeknights": "Pazartesiden cumaya {{time}} saatinde, her seferinde en fazla {{minutes}} dk çalışır.",
"summaryAlternate": "Gün aşırı {{time}} saatinde, her seferinde en fazla {{minutes}} dk çalışır.",
"summaryCustom_one": "Haftada {{count}} gece {{time}} saatinde, her seferinde en fazla {{minutes}} dk çalışır.",
"summaryCustom_other": "Haftada {{count}} gece {{time}} saatinde, her seferinde en fazla {{minutes}} dk çalışır.",
"confirm": "Bu gece için kaydet",
"confirmSome": "Bu gece {{total}} profilden {{eligible}} tanesini kaydet",
"fixFirst": "Önce bunları düzeltin",
"saving": "Kaydediliyor…",
"saved": "Zamanlama kaydedildi",
"enrolled_one": "{{count}} profil kaydedildi",
"enrolled_other": "{{count}} profil kaydedildi",
"adjust": "Zamanlamayı ayarla",
"cadenceLabel": "Sıklık",
"cadenceNightly": "Her gece",
"cadenceWeeknights": "Hafta içi geceler",
"cadenceAlternate": "Gün aşırı",
"cadenceCustom_one": "Haftada {{count}} gece",
"cadenceCustom_other": "Haftada {{count}} gece",
"timeLabel": "Başlangıç saati",
"timeHint": "Her profilin parmak izi saat dilimine göre.",
"maxMinutesLabel": "En fazla dakika",
"intensityLabel": "Derinlik",
"sitesLabel": "Siteler",
"sitesPlaceholder": "Her satıra bir adres",
"sitesHint_one": "{{count}} site. Yalnızca burada listelediğiniz sayfalar ziyaret edilir.",
"sitesHint_other": "{{count}} site. Yalnızca burada listelediğiniz sayfalar ziyaret edilir.",
"sitesTooMany": "En fazla {{max}} site.",
"presetsUnavailable": "Derinlik ön ayarları yüklenemedi. Birazdan tekrar deneyin.",
"confirmBulkTitle_one": "{{count}} profil Cookie Bot'a kaydedilsin mi?",
"confirmBulkTitle_other": "{{count}} profil Cookie Bot'a kaydedilsin mi?",
"confirmBulkDescription_one": "Bu, ortak uzak saatlerinizden bir gecelik çalışma ayırır. Saati ve siteleri sonraki adımda seçersiniz.",
"confirmBulkDescription_other": "Bu, ortak uzak saatlerinizden {{count}} gecelik çalışma ayırır. Saati ve siteleri sonraki adımda seçersiniz.",
"confirmBulkButton_one": "{{count}} profille devam et",
"confirmBulkButton_other": "{{count}} profille devam et",
"sitesRequired": "En az bir site ekleyin.",
"addSitesFirst": "Önce bir site ekleyin",
"presetsMissing": "Derinlik ön ayarları kullanılamıyor"
},
"preset": {
"light": "Hafif",
"balanced": "Standart",
"deep": "Derin"
},
"preflight": {
"ineligible_one": "{{count}} profil uzaktan çalıştırılamıyor",
"ineligible_other": "{{count}} profil uzaktan çalıştırılamıyor",
"reasonSync": "Eşitleme kapalı",
"reasonEncrypted": "Uçtan uca şifreli",
"reasonNoFingerprint": "Kayıtlı işletim sistemi yok",
"reasonCrossOs": "{{os}} için oluşturuldu — eşleşen uzak makine yok",
"reasonNoExitNode": "Proxy veya VPN yok",
"fixSync": "Eşitlemeyi aç",
"fixEncrypted": "Eşitleme ayarlarını aç",
"fixProxy": "Bir proxy ata",
"fixFailed": "Bu düzeltme uygulanamadı.",
"exitNodeHint": "Proxy veya VPN olmadan çalışma, filonun kendi veri merkezi adresinden çıkar. Bir barındırma ağından saatlerce gelen trafik, profilin kimliğine hiç ısıtmamaktan daha çok zarar verir."
},
"conflict": {
"title": "{{email}} bu profili zaten {{time}} saatinde ısıtıyor",
"detail": "Tek bir profildeki iki zamanlama saatleri iki kez harcar ve çalışma sırasında çakışabilir.",
"keepTheirs": "Onun zamanlamasını kullan",
"replace": "Benimkiyle değiştir",
"replaceForbidden": "Başkasının zamanlamasını yalnızca ekip sahibi veya yöneticisi değiştirebilir.",
"askThem": "{{email}} adresini kopyala",
"emailCopied": "E-posta kopyalandı",
"copyFailed": "Adres kopyalanamadı."
},
"actionBar": {
"enrol": "Cookie Bot'a kaydet",
"proRequired": "Cookie Bot için Pro veya Team planı gerekir",
"noneEligible": "Seçili profillerin hiçbiri uzaktan ısıtılamaz"
},
"actions": {
"enrol": "Cookie Bot'a kaydet",
"editSchedule": "Zamanlamayı düzenle",
"runNow": "Şimdi çalıştır",
"runStarted": "Çalışma başlatıldı",
"viewActivity": "Etkinliği görüntüle",
"runNotStarted": "Çalışma başlamadı: {{reason}}"
},
"state": {
"enrolled": "Kayıtlı",
"notEnrolled": "Kayıtlı değil",
"paused": "Duraklatıldı",
"summary": "{{cadence}}, {{time}}",
"rowMenu": "{{name}} için Cookie Bot seçenekleri",
"blocked": "Çalışamıyor: {{reason}}"
},
"team": {
"title": "Ekip kullanımı",
"pooled_one": "{{total}} saatin {{used}} saati, {{count}} koltuk",
"pooled_other": "{{total}} saatin {{used}} saati, {{count}} koltuk arasında ortak",
"hours": "{{hours}} sa",
"legendBot": "Cookie Bot",
"legendInteractive": "Etkileşimli",
"columnMember": "Üye",
"columnRuns": "Çalışmalar",
"columnHours": "Saat",
"columnShare": "Pay",
"noActivity": "Bu dönemde uzak oturum yok.",
"soloNote": "Şimdilik yalnızca kendi kullanımınız. Havuzun üyelere göre dağılımını görmek için ekip arkadaşlarınızı davet edin.",
"runsFailed": "· {{n}} başarısız"
},
"closeReason": {
"stoppedByUser": "Elle durduruldu",
"maxDuration": "Süre sınırına ulaşıldı",
"other": "Sona erdi: {{reason}}"
},
"outcome": {
"notEntitled": "Planınıza dahil değil",
"syncDisabled": "Bu profilde bulut eşitlemesi kapalı",
"encryptedSync": "Uçtan uca şifreli eşitleme desteklenmiyor",
"proxyRequired": "Bağlı proxy veya VPN yok",
"touchFingerprint": "Dokunmatik parmak izleri desteklenmiyor",
"platformUnsupported": "Bu profilin sistemini çalıştıran makine yok",
"noSites": "Ziyaret edilecek site yok",
"quotaExhausted": "Uzak saatler tükendi",
"profileLocked": "Profil başka bir yerde açıktı",
"noCapacity": "Boş makine yoktu",
"managerError": "Filo tarayıcıyı başlatamadı",
"budgetExceeded": "Gecenin süresi doldu",
"cancelledByUser": "Elle durduruldu",
"unknown": "Bilinmeyen neden ({{code}})"
}
}
}
+326 -9
View File
@@ -258,7 +258,8 @@
"emptyCreate": "Tạo hồ sơ",
"emptyImport": "Nhập hồ sơ",
"emptyFilteredTitle": "Không tìm thấy hồ sơ",
"emptyFilteredHint": "Không có hồ sơ nào khớp với nhóm hoặc tìm kiếm này. Hãy thử bộ lọc khác hoặc tạo mới."
"emptyFilteredHint": "Không có hồ sơ nào khớp với nhóm hoặc tìm kiếm này. Hãy thử bộ lọc khác hoặc tạo mới.",
"bot": "Bot"
},
"actions": {
"launch": "Khởi chạy",
@@ -1479,8 +1480,8 @@
"createGroup": "Tạo nhóm",
"noGroups": "Chưa tạo nhóm nào. Tạo nhóm đầu tiên bằng nút phía trên.",
"loading": "Đang tải nhóm...",
"profileCount_one": "{{count}} profile",
"profileCount_other": "{{count}} profile",
"profileCount_one": "{{count}} hồ sơ",
"profileCount_other": "{{count}} hồ sơ",
"groupsLabel": "Nhóm",
"profilesCol": "Profile",
"syncCannotDisable": "Không thể tắt đồng bộ khi nhóm này được sử dụng bởi profile đã đồng bộ",
@@ -1851,7 +1852,35 @@
"vlessConfigInvalid": "URI VLESS không hợp lệ.",
"xrayUnavailable": "Xray-core không khả dụng trên hệ thống này.",
"xrayUnsupportedOs": "VLESS yêu cầu macOS 12 trở lên.",
"xrayStartFailed": "Không thể khởi động Xray-core."
"xrayStartFailed": "Không thể khởi động Xray-core.",
"cloudNotSignedIn": "Hãy đăng nhập tài khoản Donut Browser để dùng tính năng này.",
"cloudUnreachable": "Không kết nối được tới máy chủ Donut Browser. Hãy kiểm tra kết nối và thử lại.",
"cloudRequestFailed": "Yêu cầu thất bại. Hãy thử lại sau giây lát.",
"remoteRateLimited": "Quá nhiều yêu cầu. Hãy đợi một lát rồi thử lại.",
"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.",
"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.",
"remoteSyncInProgress": "Hồ sơ này vẫn đang tải lên đồng bộ đám mây. Hãy đợi đồng bộ xong rồi thử lại.",
"remoteHoursExhausted": "Bạn đã dùng hết {{used}} trong {{granted}} giờ từ xa của tháng này.",
"notTeamMember": "Bạn không thuộc nhóm nào.",
"cookieBotNotEntitled": "Gói của bạn không bao gồm Cookie Bot.",
"cookieBotNotEnrolled": "Hồ sơ này chưa được thiết lập cho Cookie Bot.",
"cookieBotScheduleConflict": "{{email}} đã làm ấm hồ sơ này lúc {{time}}.",
"cookieBotRunInProgress": "Đã có một lần chạy đang diễn ra cho hồ sơ này.",
"cookieBotRunNotFound": "Lần chạy đó không còn tồn tại.",
"cookieBotInvalidSchedule": "Lịch chạy đó không hợp lệ. Hãy kiểm tra giờ chạy, các ngày và thời lượng.",
"cookieBotInvalidTimezone": "{{timezone}} không phải múi giờ mà máy chủ nhận diện được.",
"cookieBotInvalidPeriod": "Kỳ đó không hợp lệ. Hãy dùng dạng tháng như 2026-08.",
"cookieBotSiteLimit": "Nhập từ {{min}} đến {{max}} trang, mỗi trang là một địa chỉ http hoặc https đầy đủ.",
"cookieBotRequiresCloudSync": "Hãy bật đồng bộ đám mây cho hồ sơ này trước: máy từ xa không có cách nào khác để lấy hồ sơ.",
"cookieBotEncryptedSyncUnsupported": "Hồ sơ này dùng đồng bộ mã hóa đầu cuối mà máy từ xa không giải mã được. Hãy chuyển sang đồng bộ thường.",
"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ơ.",
"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."
},
"rail": {
"profiles": "Profile",
@@ -1870,7 +1899,9 @@
},
"network": "Mạng",
"integrations": "Tích hợp",
"account": "Tài khoản"
"account": "Tài khoản",
"cookieBot": "Cookie Bot",
"cookieBotRunning": "Cookie Bot — đang chạy"
},
"pageTitle": {
"proxies": "Mạng",
@@ -1881,7 +1912,8 @@
"integrations": "Tích hợp",
"account": "Tài khoản",
"import": "Nhập profile",
"shortcuts": "Phím tắt"
"shortcuts": "Phím tắt",
"cookieBot": "Cookie Bot"
},
"encryption": {
"required": {
@@ -1924,7 +1956,8 @@
},
"tabs": {
"account": "Tài khoản",
"selfHosted": "Tự lưu trữ"
"selfHosted": "Tự lưu trữ",
"teamUsage": "Mức dùng của nhóm"
},
"selfHosted": {
"title": "Máy chủ đồng bộ tự lưu trữ",
@@ -1937,7 +1970,8 @@
},
"deviceOrdinal": "{{ordinal}} trên {{count}}",
"automationPrimaryOnly": "Tự động hóa trình duyệt chỉ chạy trên thiết bị chính của bạn (Thiết bị 1). Đăng xuất ở đó để sử dụng tại đây.",
"automationActiveHere": "Tự động hóa trình duyệt đang hoạt động trên thiết bị này."
"automationActiveHere": "Tự động hóa trình duyệt đang hoạt động trên thiết bị này.",
"viewTeamUsage": "Xem mức dùng của nhóm"
},
"shortcutsPage": {
"title": "Phím tắt",
@@ -1970,7 +2004,8 @@
"goGroups": "Đi đến Nhóm",
"goIntegrations": "Đi đến Tích hợp",
"goAccount": "Đi đến Tài khoản",
"goSettings": "Đi đến Cài đặt"
"goSettings": "Đi đến Cài đặt",
"goCookieBot": "Cookie Bot"
},
"closeConfirm": {
"title": "Đóng Donut Browser?",
@@ -2102,5 +2137,287 @@
"matchToProxy": "Khớp vân tay với proxy",
"matching": "Đang khớp…",
"matchSuccess": "Đã cập nhật vân tay để khớp với proxy. Khởi động lại hồ sơ để áp dụng."
},
"cookieBot": {
"title": "Cookie Bot",
"description": "Làm ấm hồ sơ qua đêm trên máy từ xa.",
"tabs": {
"overview": "Tổng quan",
"schedule": "Lịch chạy",
"activity": "Hoạt động",
"team": "Nhóm"
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot làm ấm hồ sơ của bạn qua đêm trên máy từ xa, giúp chúng giữ được cookie và lịch sử mà không cần bật máy tính của bạn. Cần gói Pro hoặc Team."
},
"empty": {
"title": "Chưa có hồ sơ nào được đăng ký",
"hint": "Chọn một hồ sơ và bot sẽ làm ấm nó qua đêm trên máy từ xa. Máy tính của bạn có thể tắt.",
"cta": "Đăng ký một hồ sơ"
},
"tonight": {
"label": "Đêm nay",
"nextRun": "Lần chạy tới lúc {{time}}",
"dueCount_one": "{{count}} hồ sơ đến hạn",
"dueCount_other": "{{count}} hồ sơ đến hạn",
"nothingScheduled": "Chưa lên lịch gì"
},
"lastDay": {
"label": "24 giờ qua",
"none": "Chưa có lần chạy nào",
"ran_one": "{{count}} đã chạy",
"ran_other": "{{count}} đã chạy",
"partial_one": "{{count}} một phần",
"partial_other": "{{count}} một phần",
"failed_one": "{{count}} thất bại",
"failed_other": "{{count}} thất bại"
},
"chart": {
"machineTime": "Thời gian máy mỗi đêm",
"minutes": "{{minutes}} phút"
},
"hours": {
"label": "Giờ từ xa",
"remaining": "Còn {{remaining}} giờ trong {{total}}",
"remainingOf": "trên {{total}} giờ",
"used": "Đã dùng {{used}} trong {{total}} giờ",
"resets": "Đặt lại vào {{date}}",
"exhausted": "Đã hết giờ từ xa. Các lịch chạy vẫn được giữ và sẽ tiếp tục ở chu kỳ sau.",
"exhaustedOn": "Đã hết giờ từ xa. Các lịch chạy vẫn được giữ và sẽ tiếp tục vào {{date}}.",
"estimate": "Khoảng {{hours}} giờ mỗi tuần · còn {{remaining}} giờ trong chu kỳ này",
"estimateOverBudget": "Cần khoảng {{hours}} giờ mỗi tuần — chỉ còn {{remaining}} giờ",
"estimateOnly": "Khoảng {{hours}} giờ mỗi tuần"
},
"enrolled": {
"columnProfile": "Hồ sơ",
"columnCadence": "Tần suất",
"columnTime": "Giờ",
"columnNextRun": "Lần chạy tới",
"columnLastRun": "Lần chạy trước",
"enrolProfiles": "Đăng ký hồ sơ",
"edit": "Sửa lịch chạy",
"neverRun": "Chưa bao giờ",
"pausedNoHours": "Tạm dừng — hết giờ",
"profileMissing": "Hồ sơ đó không có trên máy tính này."
},
"schedule": {
"empty": "Chưa có gì được lên lịch.",
"quietHours_one": "{{count}} giờ trống",
"quietHours_other": "{{count}} giờ trống",
"crowded_one": "{{count}} hồ sơ khởi động cùng lúc",
"crowded_other": "{{count}} hồ sơ khởi động cùng lúc",
"unenrol": "Gỡ khỏi Cookie Bot",
"unenrolTitle": "Gỡ {{name}} khỏi Cookie Bot?",
"unenrolDescription": "Việc làm ấm sẽ dừng ngay đêm nay. Các lần chạy trước vẫn nằm trong tab Hoạt động.",
"unenrolled": "Đã gỡ khỏi Cookie Bot"
},
"status": {
"provisioning": "Đang chuẩn bị máy",
"ready": "Đang tải hồ sơ",
"warming": "Đang làm ấm",
"finished": "Đang kết thúc",
"failed": "Thất bại"
},
"runStatus": {
"pending": "Trong hàng đợi",
"running": "Đang chạy",
"succeeded": "Hoàn tất",
"partial": "Một phần",
"failed": "Thất bại",
"skipped": "Đã bỏ qua",
"cancelled": "Đã dừng"
},
"running": {
"label": "Đang chạy",
"more": "+{{count}} nữa",
"stop": "Dừng lần chạy",
"stopped": "Đã dừng lần chạy"
},
"live": {
"idle": "Hiện không có gì đang chạy",
"streamOffline": "Cập nhật trực tiếp đang ngoại tuyến",
"streamOfflineDetail": "Cập nhật trực tiếp đang ngoại tuyến — đang kết nối lại. Các lần chạy bên dưới có thể đã cũ.",
"unnamedSession": "Phiên từ xa",
"notStartedYet": "Máy chưa báo về thời điểm bắt đầu.",
"sitesProgress": "{{visited}} trong {{total}} trang",
"sitesUnknown": "Chưa có báo cáo về các trang",
"consentHandled_one": "Đã xử lý {{count}} hộp thoại đồng ý",
"consentHandled_other": "Đã xử lý {{count}} hộp thoại đồng ý",
"consentUnknown": "Chưa có báo cáo về hộp thoại đồng ý",
"billed": "Đã tính {{duration}}",
"billedUnknown": "Chưa có báo cáo về thời gian tính phí",
"chunk": "Phần {{index}} trên {{total}}"
},
"history": {
"title": "Lần chạy",
"allProfiles": "Tất cả hồ sơ",
"searchPlaceholder": "Tìm hồ sơ…",
"filterAll": "Tất cả lần chạy",
"filterComplete": "Hoàn tất",
"filterPartial": "Một phần",
"filterFailed": "Thất bại",
"columnStarted": "Bắt đầu",
"columnProfile": "Hồ sơ",
"columnDuration": "Thời lượng",
"columnSites": "Trang",
"columnStatus": "Trạng thái",
"columnOperator": "Người vận hành",
"empty": "Chưa có lần chạy nào.",
"noMatch": "Không có lần chạy nào khớp bộ lọc này.",
"unknownProfile": "Hồ sơ không xác định",
"outcome": "Lý do: {{reason}}",
"sitesVisited": "{{visited}}/{{total}}",
"sitesFailed_one": "Không truy cập được {{count}} trang",
"sitesFailed_other": "Không truy cập được {{count}} trang",
"consentHandled_one": "Đã xử lý {{count}} hộp thoại đồng ý",
"consentHandled_other": "Đã xử lý {{count}} hộp thoại đồng ý",
"sitesUnknown": "Chưa có số liệu"
},
"duration": {
"hm": "{{hours}} giờ {{minutes}} phút",
"ms": "{{minutes}} phút {{seconds}} giây",
"s": "{{seconds}} giây"
},
"picker": {
"title": "Đăng ký hồ sơ",
"description": "Chọn các hồ sơ mà bot sẽ làm ấm qua đêm.",
"searchPlaceholder": "Tìm hồ sơ…",
"noProfiles": "Không có hồ sơ nào khớp.",
"alreadyEnrolled": "Đã đăng ký",
"continue_one": "Tiếp tục với {{count}}",
"continue_other": "Tiếp tục với {{count}}"
},
"enrol": {
"titleOne": "Đăng ký {{name}}",
"titleCount_one": "Đăng ký {{count}} hồ sơ",
"titleCount_other": "Đăng ký {{count}} hồ sơ",
"editTitle": "Sửa lịch chạy",
"description": "Bot mở hồ sơ trên máy từ xa và duyệt các trang bạn liệt kê. Máy tính của bạn có thể tắt.",
"summaryNightly": "Chạy mỗi đêm lúc {{time}}, tối đa {{minutes}} phút mỗi lần.",
"summaryWeeknights": "Chạy từ thứ Hai đến thứ Sáu lúc {{time}}, tối đa {{minutes}} phút mỗi lần.",
"summaryAlternate": "Chạy cách đêm lúc {{time}}, tối đa {{minutes}} phút mỗi lần.",
"summaryCustom_one": "Chạy {{count}} đêm mỗi tuần lúc {{time}}, tối đa {{minutes}} phút mỗi lần.",
"summaryCustom_other": "Chạy {{count}} đêm mỗi tuần lúc {{time}}, tối đa {{minutes}} phút mỗi lần.",
"confirm": "Đăng ký cho đêm nay",
"confirmSome": "Đăng ký {{eligible}} trong {{total}} cho đêm nay",
"fixFirst": "Hãy khắc phục những mục này trước",
"saving": "Đang đăng ký…",
"saved": "Đã lưu lịch chạy",
"enrolled_one": "Đã đăng ký {{count}} hồ sơ",
"enrolled_other": "Đã đăng ký {{count}} hồ sơ",
"adjust": "Điều chỉnh lịch chạy",
"cadenceLabel": "Tần suất",
"cadenceNightly": "Mỗi đêm",
"cadenceWeeknights": "Đêm trong tuần",
"cadenceAlternate": "Cách đêm",
"cadenceCustom_one": "{{count}} đêm mỗi tuần",
"cadenceCustom_other": "{{count}} đêm mỗi tuần",
"timeLabel": "Giờ bắt đầu",
"timeHint": "Theo múi giờ trong dấu vân tay của từng hồ sơ.",
"maxMinutesLabel": "Số phút tối đa",
"intensityLabel": "Độ sâu",
"sitesLabel": "Trang web",
"sitesPlaceholder": "Mỗi dòng một địa chỉ",
"sitesHint_one": "{{count}} trang. Chỉ những trang bạn liệt kê ở đây mới được truy cập.",
"sitesHint_other": "{{count}} trang. Chỉ những trang bạn liệt kê ở đây mới được truy cập.",
"sitesTooMany": "Tối đa {{max}} trang.",
"presetsUnavailable": "Không tải được các mức độ sâu định sẵn. Hãy thử lại sau giây lát.",
"confirmBulkTitle_one": "Đăng ký {{count}} hồ sơ vào Cookie Bot?",
"confirmBulkTitle_other": "Đăng ký {{count}} hồ sơ vào Cookie Bot?",
"confirmBulkDescription_one": "Việc này đặt trước một lần chạy đêm, trừ vào quỹ giờ từ xa dùng chung. Bạn sẽ chọn giờ và các trang ở bước tiếp theo.",
"confirmBulkDescription_other": "Việc này đặt trước {{count}} lần chạy đêm, trừ vào quỹ giờ từ xa dùng chung. Bạn sẽ chọn giờ và các trang ở bước tiếp theo.",
"confirmBulkButton_one": "Tiếp tục với {{count}} hồ sơ",
"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"
},
"preset": {
"light": "Nhẹ",
"balanced": "Tiêu chuẩn",
"deep": "Sâu"
},
"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",
"reasonSync": "Đồng bộ đang tắt",
"reasonEncrypted": "Mã hóa đầu cuối",
"reasonNoFingerprint": "Chưa ghi nhận hệ điều hành",
"reasonCrossOs": "Tạo cho {{os}} — không có máy từ xa phù hợp",
"reasonNoExitNode": "Không có proxy hoặc VPN",
"fixSync": "Bật đồng bộ",
"fixEncrypted": "Mở cài đặt đồng bộ",
"fixProxy": "Gán một proxy",
"fixFailed": "Không áp dụng được cách khắc phục đó.",
"exitNodeHint": "Không có proxy hoặc VPN, lần chạy sẽ đi ra từ chính địa chỉ trung tâm dữ liệu của hệ thống máy. Nhiều giờ lưu lượng từ một mạng lưu trữ gây hại cho danh tính hồ sơ hơn cả việc không làm ấm."
},
"conflict": {
"title": "{{email}} đã làm ấm hồ sơ này lúc {{time}}",
"detail": "Hai lịch chạy trên cùng một hồ sơ tiêu tốn giờ gấp đôi và có thể xung đột giữa chừng.",
"keepTheirs": "Dùng lịch của họ",
"replace": "Thay bằng lịch của tôi",
"replaceForbidden": "Chỉ chủ sở hữu hoặc quản trị viên nhóm mới đổi được lịch của người khác.",
"askThem": "Sao chép {{email}}",
"emailCopied": "Đã sao chép email",
"copyFailed": "Không sao chép được địa chỉ."
},
"actionBar": {
"enrol": "Đăng ký vào Cookie Bot",
"proRequired": "Cookie Bot cần gói Pro hoặc Team",
"noneEligible": "Không hồ sơ nào đã chọn có thể làm ấm từ xa"
},
"actions": {
"enrol": "Đăng ký vào Cookie Bot",
"editSchedule": "Sửa lịch chạy",
"runNow": "Chạy ngay",
"runStarted": "Đã bắt đầu chạy",
"viewActivity": "Xem hoạt động",
"runNotStarted": "Lượt chạy chưa bắt đầu: {{reason}}"
},
"state": {
"enrolled": "Đã đăng ký",
"notEnrolled": "Chưa đăng ký",
"paused": "Tạm dừng",
"summary": "{{cadence}} lúc {{time}}",
"rowMenu": "Tùy chọn Cookie Bot cho {{name}}",
"blocked": "Không thể chạy: {{reason}}"
},
"team": {
"title": "Mức dùng của nhóm",
"pooled_one": "{{used}} trong {{total}} giờ, {{count}} chỗ",
"pooled_other": "{{used}} trong {{total}} giờ dùng chung cho {{count}} chỗ",
"hours": "{{hours}} giờ",
"legendBot": "Cookie Bot",
"legendInteractive": "Tương tác",
"columnMember": "Thành viên",
"columnRuns": "Lần chạy",
"columnHours": "Giờ",
"columnShare": "Tỷ lệ",
"noActivity": "Không có phiên từ xa nào trong kỳ này.",
"soloNote": "Hiện chỉ có mức dùng của bạn. Mời đồng đội để xem quỹ giờ chia theo từng thành viên.",
"runsFailed": "· {{n}} thất bại"
},
"closeReason": {
"stoppedByUser": "Đã dừng thủ công",
"maxDuration": "Đã đạt giới hạn thời gian",
"other": "Đã kết thúc: {{reason}}"
},
"outcome": {
"notEntitled": "Không có trong gói của bạn",
"syncDisabled": "Hồ sơ này đang tắt đồng bộ đám mây",
"encryptedSync": "Không hỗ trợ đồng bộ mã hoá đầu cuối",
"proxyRequired": "Chưa gắn proxy hoặc VPN",
"touchFingerprint": "Không hỗ trợ vân tay thiết bị cảm ứng",
"platformUnsupported": "Không có máy nào chạy hệ điều hành của hồ sơ này",
"noSites": "Không có trang nào để truy cập",
"quotaExhausted": "Đã dùng hết số giờ từ xa",
"profileLocked": "Hồ sơ đang mở ở nơi khác",
"noCapacity": "Không có máy nào rảnh",
"managerError": "Cụm máy không khởi động được trình duyệt",
"budgetExceeded": "Đã hết thời lượng dành cho đêm nay",
"cancelledByUser": "Đã dừng thủ công",
"unknown": "Lý do không xác định ({{code}})"
}
}
}
+324 -7
View File
@@ -258,7 +258,8 @@
"emptyCreate": "创建配置文件",
"emptyImport": "导入配置文件",
"emptyFilteredTitle": "未找到配置文件",
"emptyFilteredHint": "没有符合此分组或搜索的配置文件。请尝试其他筛选条件或新建一个。"
"emptyFilteredHint": "没有符合此分组或搜索的配置文件。请尝试其他筛选条件或新建一个。",
"bot": "机器人"
},
"actions": {
"launch": "启动",
@@ -1851,7 +1852,35 @@
"vlessConfigInvalid": "VLESS URI 无效。",
"xrayUnavailable": "此系统无法使用 Xray-core。",
"xrayUnsupportedOs": "VLESS 需要 macOS 12 或更高版本。",
"xrayStartFailed": "无法启动 Xray-core。"
"xrayStartFailed": "无法启动 Xray-core。",
"cloudNotSignedIn": "请先登录你的 Donut Browser 账号再使用此功能。",
"cloudUnreachable": "无法连接 Donut Browser 服务器。请检查网络后重试。",
"cloudRequestFailed": "请求失败。请稍后再试。",
"remoteRateLimited": "请求过于频繁。请稍候再试。",
"remoteNoCapacity": "当前没有空闲的远程机器。请几分钟后再试。",
"remoteNotEntitled": "你的套餐不包含远程运行。",
"remoteSessionRefused": "远程机器拒绝了此会话。",
"remoteSessionNotFound": "该远程会话已不存在。",
"remoteSessionConflict": "此配置文件已在别处打开。",
"remoteSyncInProgress": "此配置文件仍在上传到云同步。请等待同步完成后重试。",
"remoteHoursExhausted": "本月 {{granted}} 小时远程时长已全部用完(已用 {{used}})。",
"notTeamMember": "你不属于任何团队。",
"cookieBotNotEntitled": "你的套餐不包含 Cookie Bot。",
"cookieBotNotEnrolled": "此配置文件尚未为 Cookie Bot 配置。",
"cookieBotScheduleConflict": "{{email}} 已在 {{time}} 养这个配置文件。",
"cookieBotRunInProgress": "此配置文件已有一次运行正在进行。",
"cookieBotRunNotFound": "该运行记录已不存在。",
"cookieBotInvalidSchedule": "该计划无效。请检查运行时间、日期和时长。",
"cookieBotInvalidTimezone": "{{timezone}} 不是服务器可识别的时区。",
"cookieBotInvalidPeriod": "该周期无效。请使用类似 2026-08 的月份。",
"cookieBotSiteLimit": "请输入 {{min}} 到 {{max}} 个网站,每个都要是完整的 http 或 https 地址。",
"cookieBotRequiresCloudSync": "请先为此配置文件开启云同步:远程机器没有其他方式获取它。",
"cookieBotEncryptedSyncUnsupported": "此配置文件使用端到端加密同步,远程机器无法解密。请改为常规同步。",
"cookieBotUnknownPlatform": "此配置文件没有记录操作系统,无法匹配到机器。",
"cookieBotUnsupportedPlatform": "Cookie Bot 无法运行 {{platform}} 配置文件。仅支持 Windows 和 macOS 配置文件。",
"cookieBotRequiresExitNode": "请先绑定代理或 VPN。否则运行会从数据中心地址发出,损害配置文件的身份。",
"unknownCode": "出现问题: {{code}}",
"cookieBotTouchFingerprintUnsupported": "该配置文件声称是触摸设备,机器人无法操作。请使用桌面端指纹。"
},
"rail": {
"profiles": "配置文件",
@@ -1870,7 +1899,9 @@
},
"network": "网络",
"integrations": "集成",
"account": "账号"
"account": "账号",
"cookieBot": "Cookie Bot",
"cookieBotRunning": "Cookie Bot — 正在运行"
},
"pageTitle": {
"proxies": "网络",
@@ -1881,7 +1912,8 @@
"integrations": "集成",
"account": "账户",
"import": "导入配置文件",
"shortcuts": "键盘快捷键"
"shortcuts": "键盘快捷键",
"cookieBot": "Cookie Bot"
},
"encryption": {
"required": {
@@ -1924,7 +1956,8 @@
},
"tabs": {
"account": "账户",
"selfHosted": "自托管"
"selfHosted": "自托管",
"teamUsage": "团队用量"
},
"selfHosted": {
"title": "自托管同步服务器",
@@ -1937,7 +1970,8 @@
},
"deviceOrdinal": "第 {{ordinal}} 台,共 {{count}} 台",
"automationPrimaryOnly": "浏览器自动化仅在您的主设备(设备 1)上运行。请在该设备上退出登录,才能在此设备上使用。",
"automationActiveHere": "浏览器自动化已在此设备上启用。"
"automationActiveHere": "浏览器自动化已在此设备上启用。",
"viewTeamUsage": "查看团队用量"
},
"shortcutsPage": {
"title": "键盘快捷键",
@@ -1970,7 +2004,8 @@
"goGroups": "转到分组",
"goIntegrations": "转到集成",
"goAccount": "转到账户",
"goSettings": "转到设置"
"goSettings": "转到设置",
"goCookieBot": "Cookie Bot"
},
"closeConfirm": {
"title": "关闭 Donut Browser",
@@ -2102,5 +2137,287 @@
"matchToProxy": "将指纹匹配到代理",
"matching": "匹配中…",
"matchSuccess": "指纹已更新以匹配代理。重新启动配置文件以生效。"
},
"cookieBot": {
"title": "Cookie Bot",
"description": "在远程机器上通宵养号。",
"tabs": {
"overview": "概览",
"schedule": "计划",
"activity": "活动",
"team": "团队"
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。需要 Pro 或 Team 套餐。"
},
"empty": {
"title": "尚未加入任何配置文件",
"hint": "选一个配置文件,机器人会在远程机器上通宵养号。你的电脑可以关机。",
"cta": "加入一个配置文件"
},
"tonight": {
"label": "今晚",
"nextRun": "下次运行 {{time}}",
"dueCount_one": "{{count}} 个配置文件待运行",
"dueCount_other": "{{count}} 个配置文件待运行",
"nothingScheduled": "没有安排"
},
"lastDay": {
"label": "最近 24 小时",
"none": "还没有运行记录",
"ran_one": "{{count}} 次运行",
"ran_other": "{{count}} 次运行",
"partial_one": "{{count}} 次部分完成",
"partial_other": "{{count}} 次部分完成",
"failed_one": "{{count}} 次失败",
"failed_other": "{{count}} 次失败"
},
"chart": {
"machineTime": "每晚机器时长",
"minutes": "{{minutes}} 分钟"
},
"hours": {
"label": "远程时长",
"remaining": "{{total}} 小时中还剩 {{remaining}} 小时",
"remainingOf": "/ {{total}} 小时",
"used": "{{total}} 小时中已用 {{used}} 小时",
"resets": "{{date}} 重置",
"exhausted": "远程时长已用完。计划仍然保留,将在下个周期恢复。",
"exhaustedOn": "远程时长已用完。计划仍然保留,将于 {{date}} 恢复。",
"estimate": "每周约 {{hours}} 小时 · 本周期还剩 {{remaining}} 小时",
"estimateOverBudget": "每周约需 {{hours}} 小时,但只剩 {{remaining}} 小时",
"estimateOnly": "每周约 {{hours}} 小时"
},
"enrolled": {
"columnProfile": "配置文件",
"columnCadence": "频率",
"columnTime": "时间",
"columnNextRun": "下次运行",
"columnLastRun": "上次运行",
"enrolProfiles": "加入配置文件",
"edit": "编辑计划",
"neverRun": "从未",
"pausedNoHours": "已暂停 — 时长用尽",
"profileMissing": "该配置文件不在这台电脑上。"
},
"schedule": {
"empty": "还没有任何安排。",
"quietHours_one": "{{count}} 个空闲小时",
"quietHours_other": "{{count}} 个空闲小时",
"crowded_one": "{{count}} 个配置文件同时开始",
"crowded_other": "{{count}} 个配置文件同时开始",
"unenrol": "从 Cookie Bot 移除",
"unenrolTitle": "将 {{name}} 从 Cookie Bot 移除?",
"unenrolDescription": "今晚起停止养号。以往的运行记录仍保留在“活动”中。",
"unenrolled": "已从 Cookie Bot 移除"
},
"status": {
"provisioning": "正在准备机器",
"ready": "正在加载配置文件",
"warming": "养号中",
"finished": "正在收尾",
"failed": "失败"
},
"runStatus": {
"pending": "排队中",
"running": "运行中",
"succeeded": "已完成",
"partial": "部分完成",
"failed": "失败",
"skipped": "已跳过",
"cancelled": "已停止"
},
"running": {
"label": "正在运行",
"more": "另有 {{count}} 个",
"stop": "停止运行",
"stopped": "运行已停止"
},
"live": {
"idle": "当前没有任务在运行",
"streamOffline": "实时更新已离线",
"streamOfflineDetail": "实时更新已离线,正在重新连接。下方的运行记录可能不是最新的。",
"unnamedSession": "远程会话",
"notStartedYet": "机器尚未报告开始时间。",
"sitesProgress": "{{total}} 个网站中的 {{visited}} 个",
"sitesUnknown": "尚未报告网站信息",
"consentHandled_one": "已处理 {{count}} 个同意提示",
"consentHandled_other": "已处理 {{count}} 个同意提示",
"consentUnknown": "尚未报告同意提示",
"billed": "已计费 {{duration}}",
"billedUnknown": "尚未报告计费时长",
"chunk": "第 {{index}} 段,共 {{total}} 段"
},
"history": {
"title": "运行记录",
"allProfiles": "所有配置文件",
"searchPlaceholder": "搜索配置文件…",
"filterAll": "全部运行",
"filterComplete": "已完成",
"filterPartial": "部分完成",
"filterFailed": "失败",
"columnStarted": "开始",
"columnProfile": "配置文件",
"columnDuration": "时长",
"columnSites": "网站",
"columnStatus": "状态",
"columnOperator": "操作者",
"empty": "还没有运行记录。",
"noMatch": "没有符合此筛选条件的运行。",
"unknownProfile": "未知配置文件",
"outcome": "原因:{{reason}}",
"sitesVisited": "{{visited}}/{{total}}",
"sitesFailed_one": "{{count}} 个网站无法访问",
"sitesFailed_other": "{{count}} 个网站无法访问",
"consentHandled_one": "已处理 {{count}} 个同意提示",
"consentHandled_other": "已处理 {{count}} 个同意提示",
"sitesUnknown": "暂无数据"
},
"duration": {
"hm": "{{hours}} 小时 {{minutes}} 分",
"ms": "{{minutes}} 分 {{seconds}} 秒",
"s": "{{seconds}} 秒"
},
"picker": {
"title": "加入配置文件",
"description": "选择机器人应在夜间养号的配置文件。",
"searchPlaceholder": "搜索配置文件…",
"noProfiles": "没有匹配的配置文件。",
"alreadyEnrolled": "已加入",
"continue_one": "继续({{count}} 个)",
"continue_other": "继续({{count}} 个)"
},
"enrol": {
"titleOne": "加入 {{name}}",
"titleCount_one": "加入 {{count}} 个配置文件",
"titleCount_other": "加入 {{count}} 个配置文件",
"editTitle": "编辑计划",
"description": "机器人会在远程机器上打开配置文件,浏览你列出的网站。你的电脑可以关机。",
"summaryNightly": "每晚 {{time}} 运行,每次最多 {{minutes}} 分钟。",
"summaryWeeknights": "周一至周五 {{time}} 运行,每次最多 {{minutes}} 分钟。",
"summaryAlternate": "隔晚 {{time}} 运行,每次最多 {{minutes}} 分钟。",
"summaryCustom_one": "每周 {{count}} 晚,于 {{time}} 运行,每次最多 {{minutes}} 分钟。",
"summaryCustom_other": "每周 {{count}} 晚,于 {{time}} 运行,每次最多 {{minutes}} 分钟。",
"confirm": "今晚起加入",
"confirmSome": "今晚加入 {{total}} 个中的 {{eligible}} 个",
"fixFirst": "请先解决这些问题",
"saving": "正在加入…",
"saved": "计划已保存",
"enrolled_one": "已加入 {{count}} 个配置文件",
"enrolled_other": "已加入 {{count}} 个配置文件",
"adjust": "调整计划",
"cadenceLabel": "频率",
"cadenceNightly": "每晚",
"cadenceWeeknights": "工作日夜间",
"cadenceAlternate": "隔晚一次",
"cadenceCustom_one": "每周 {{count}} 晚",
"cadenceCustom_other": "每周 {{count}} 晚",
"timeLabel": "开始时间",
"timeHint": "按各配置文件指纹所在时区计算。",
"maxMinutesLabel": "最长分钟数",
"intensityLabel": "深度",
"sitesLabel": "网站",
"sitesPlaceholder": "每行一个网址",
"sitesHint_one": "{{count}} 个网站。只会访问你在这里列出的页面。",
"sitesHint_other": "{{count}} 个网站。只会访问你在这里列出的页面。",
"sitesTooMany": "最多 {{max}} 个网站。",
"presetsUnavailable": "无法加载深度预设。请稍后再试。",
"confirmBulkTitle_one": "将 {{count}} 个配置文件加入 Cookie Bot",
"confirmBulkTitle_other": "将 {{count}} 个配置文件加入 Cookie Bot",
"confirmBulkDescription_one": "这会占用共享远程时长预订一次夜间运行。时间和网站将在下一步选择。",
"confirmBulkDescription_other": "这会占用共享远程时长预订 {{count}} 次夜间运行。时间和网站将在下一步选择。",
"confirmBulkButton_one": "继续({{count}} 个配置文件)",
"confirmBulkButton_other": "继续({{count}} 个配置文件)",
"sitesRequired": "请至少添加一个网站。",
"addSitesFirst": "请先添加网站",
"presetsMissing": "深度预设不可用"
},
"preset": {
"light": "轻度",
"balanced": "标准",
"deep": "深度"
},
"preflight": {
"ineligible_one": "{{count}} 个配置文件无法远程运行",
"ineligible_other": "{{count}} 个配置文件无法远程运行",
"reasonSync": "同步已关闭",
"reasonEncrypted": "端到端加密",
"reasonNoFingerprint": "没有记录操作系统",
"reasonCrossOs": "为 {{os}} 创建 — 没有匹配的远程机器",
"reasonNoExitNode": "没有代理或 VPN",
"fixSync": "开启同步",
"fixEncrypted": "打开同步设置",
"fixProxy": "绑定代理",
"fixFailed": "无法应用该修复。",
"exitNodeHint": "没有代理或 VPN 时,流量会从机器集群自己的数据中心地址发出。来自托管网络的长时间流量对配置文件身份的损害,比完全不养号还要大。"
},
"conflict": {
"title": "{{email}} 已在 {{time}} 养这个配置文件",
"detail": "同一个配置文件上有两份计划会重复消耗时长,并可能在运行中冲突。",
"keepTheirs": "使用对方的计划",
"replace": "用我的替换",
"replaceForbidden": "只有团队所有者或管理员才能更改他人的计划。",
"askThem": "复制 {{email}}",
"emailCopied": "已复制邮箱",
"copyFailed": "无法复制该地址。"
},
"actionBar": {
"enrol": "加入 Cookie Bot",
"proRequired": "Cookie Bot 需要 Pro 或 Team 套餐",
"noneEligible": "所选配置文件都无法远程养号"
},
"actions": {
"enrol": "加入 Cookie Bot",
"editSchedule": "编辑计划",
"runNow": "立即运行",
"runStarted": "已开始运行",
"viewActivity": "查看活动",
"runNotStarted": "运行未开始:{{reason}}"
},
"state": {
"enrolled": "已加入",
"notEnrolled": "未加入",
"paused": "已暂停",
"summary": "{{cadence}} {{time}}",
"rowMenu": "{{name}} 的 Cookie Bot 选项",
"blocked": "无法运行:{{reason}}"
},
"team": {
"title": "团队用量",
"pooled_one": "{{total}} 小时中已用 {{used}} 小时,{{count}} 个席位",
"pooled_other": "{{total}} 小时中已用 {{used}} 小时,由 {{count}} 个席位共享",
"hours": "{{hours}} 小时",
"legendBot": "Cookie Bot",
"legendInteractive": "手动使用",
"columnMember": "成员",
"columnRuns": "运行次数",
"columnHours": "时长",
"columnShare": "占比",
"noActivity": "此期间没有远程会话。",
"soloNote": "目前只显示你自己的用量。邀请队友后可按成员查看时长分配。",
"runsFailed": "· {{n}} 次失败"
},
"closeReason": {
"stoppedByUser": "已手动停止",
"maxDuration": "已达时间上限",
"other": "已结束:{{reason}}"
},
"outcome": {
"notEntitled": "当前套餐不包含此功能",
"syncDisabled": "该配置文件已关闭云同步",
"encryptedSync": "不支持端到端加密同步",
"proxyRequired": "未绑定代理或 VPN",
"touchFingerprint": "不支持触摸设备指纹",
"platformUnsupported": "没有机器可运行该配置文件的系统",
"noSites": "没有要访问的网站",
"quotaExhausted": "远程时长已用完",
"profileLocked": "该配置文件已在别处打开",
"noCapacity": "没有空闲机器",
"managerError": "机器集群未能启动浏览器",
"budgetExceeded": "当晚的时长预算已用完",
"cancelledByUser": "已手动停止",
"unknown": "未知原因({{code}}"
}
}
}
+112 -1
View File
@@ -67,6 +67,39 @@ export type BackendErrorCode =
| "XRAY_UNAVAILABLE"
| "XRAY_UNSUPPORTED_OS"
| "XRAY_START_FAILED"
| "CLOUD_NOT_SIGNED_IN"
| "CLOUD_UNREACHABLE"
| "CLOUD_REQUEST_FAILED"
| "REMOTE_RATE_LIMITED"
| "REMOTE_NO_CAPACITY"
| "REMOTE_NOT_ENTITLED"
| "REMOTE_SESSION_REFUSED"
| "REMOTE_SESSION_NOT_FOUND"
| "REMOTE_SESSION_CONFLICT"
| "REMOTE_SYNC_IN_PROGRESS"
| "REMOTE_HOURS_EXHAUSTED"
| "NOT_TEAM_MEMBER"
| "COOKIE_BOT_NOT_ENTITLED"
| "COOKIE_BOT_NOT_ENROLLED"
| "COOKIE_BOT_SCHEDULE_CONFLICT"
| "COOKIE_BOT_RUN_IN_PROGRESS"
| "COOKIE_BOT_RUN_NOT_FOUND"
| "COOKIE_BOT_INVALID_SCHEDULE"
| "COOKIE_BOT_INVALID_TIMEZONE"
| "COOKIE_BOT_INVALID_PERIOD"
| "COOKIE_BOT_SITE_LIMIT"
| "COOKIE_BOT_REQUIRES_CLOUD_SYNC"
| "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED"
| "COOKIE_BOT_UNKNOWN_PLATFORM"
| "COOKIE_BOT_UNSUPPORTED_PLATFORM"
| "COOKIE_BOT_REQUIRES_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;
// without a case here the single most important refusal in the feature
// rendered as the raw machine identifier.
| "COOKIE_BOT_REQUIRES_PROXY"
| "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -256,12 +289,90 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.xrayStartFailed");
case "CLEAR_ON_CLOSE_UNAVAILABLE":
return t("backendErrors.clearOnCloseUnavailable");
case "CLOUD_NOT_SIGNED_IN":
return t("backendErrors.cloudNotSignedIn");
case "CLOUD_UNREACHABLE":
return t("backendErrors.cloudUnreachable");
case "CLOUD_REQUEST_FAILED":
return t("backendErrors.cloudRequestFailed");
case "REMOTE_RATE_LIMITED":
return t("backendErrors.remoteRateLimited");
case "REMOTE_NO_CAPACITY":
return t("backendErrors.remoteNoCapacity");
case "REMOTE_NOT_ENTITLED":
return t("backendErrors.remoteNotEntitled");
case "REMOTE_SESSION_REFUSED":
return t("backendErrors.remoteSessionRefused");
case "REMOTE_SESSION_NOT_FOUND":
return t("backendErrors.remoteSessionNotFound");
case "REMOTE_SESSION_CONFLICT":
return t("backendErrors.remoteSessionConflict");
case "REMOTE_SYNC_IN_PROGRESS":
return t("backendErrors.remoteSyncInProgress");
case "REMOTE_HOURS_EXHAUSTED":
return t("backendErrors.remoteHoursExhausted", {
granted: parsed.params?.granted ?? "0",
used: parsed.params?.used ?? "0",
});
case "NOT_TEAM_MEMBER":
return t("backendErrors.notTeamMember");
case "COOKIE_BOT_NOT_ENTITLED":
return t("backendErrors.cookieBotNotEntitled");
case "COOKIE_BOT_NOT_ENROLLED":
return t("backendErrors.cookieBotNotEnrolled");
case "COOKIE_BOT_SCHEDULE_CONFLICT":
return t("backendErrors.cookieBotScheduleConflict", {
email: parsed.params?.email ?? "",
time: parsed.params?.time ?? "",
});
case "COOKIE_BOT_RUN_IN_PROGRESS":
return t("backendErrors.cookieBotRunInProgress");
case "COOKIE_BOT_RUN_NOT_FOUND":
return t("backendErrors.cookieBotRunNotFound");
case "COOKIE_BOT_INVALID_SCHEDULE":
return t("backendErrors.cookieBotInvalidSchedule");
case "COOKIE_BOT_INVALID_TIMEZONE":
return t("backendErrors.cookieBotInvalidTimezone", {
timezone: parsed.params?.timezone ?? "",
});
case "COOKIE_BOT_INVALID_PERIOD":
return t("backendErrors.cookieBotInvalidPeriod");
case "COOKIE_BOT_SITE_LIMIT":
// The server sends both bounds. Defaulting `min` to 1 was not the
// problem — the message never mentioned a minimum at all, so a user who
// submitted no sites was told about a maximum they had not reached.
return t("backendErrors.cookieBotSiteLimit", {
min: parsed.params?.min ?? "1",
max: parsed.params?.max ?? "40",
});
case "COOKIE_BOT_REQUIRES_CLOUD_SYNC":
return t("backendErrors.cookieBotRequiresCloudSync");
case "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED":
return t("backendErrors.cookieBotEncryptedSyncUnsupported");
case "COOKIE_BOT_UNKNOWN_PLATFORM":
return t("backendErrors.cookieBotUnknownPlatform");
case "COOKIE_BOT_UNSUPPORTED_PLATFORM":
return t("backendErrors.cookieBotUnsupportedPlatform", {
platform: parsed.params?.platform ?? "",
});
case "COOKIE_BOT_REQUIRES_EXIT_NODE":
// One condition, two names: the desktop refuses it locally as
// REQUIRES_EXIT_NODE and the server refuses it as REQUIRES_PROXY. Both
// resolve to the one sentence a user can act on.
case "COOKIE_BOT_REQUIRES_PROXY":
return t("backendErrors.cookieBotRequiresExitNode");
case "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED":
return t("backendErrors.cookieBotTouchFingerprintUnsupported");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
});
default:
return err instanceof Error ? err.message : String(err);
// The payload parsed as a structured error but carries a code this build
// does not know: the server can add codes faster than the desktop ships.
// Returning the raw message here would render the literal JSON to the
// user, so show a translated line that still names the code for support.
return t("backendErrors.unknownCode", { code: String(parsed.code) });
}
}
+65
View File
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { SCHEDULE_BOUNDS } from "./cookie-bot-limits.ts";
/**
* The enrolment form validates against bounds that belong to the server. This
* file is the tripwire for that mirror.
*
* The platform list (`BOT_PLATFORMS`) and the preflight refusals are both
* pinned one by a cross-referenced comment in `cookie_bot.rs`, the other by
* tests beside it. These four numbers were not pinned by anything, so infra
* could widen `max_minutes` to 180 and the desktop would go on refusing 150
* with no field-level explanation, or cap sites at 25 and let a user fill a
* form the PUT answers with `COOKIE_BOT_INVALID_SCHEDULE`.
*/
test("the mirrored bounds are exactly what the server enforces", () => {
// Read off `validateScheduleBody` / `normaliseSites` and the constants in
// donutbrowser-infra's apps/backend/src/cookie-bot/cookie-bot-schedule.ts.
// Changing a number here without changing it there is the bug.
assert.deepEqual(
{ ...SCHEDULE_BOUNDS },
{ minMaxMinutes: 5, maxMaxMinutes: 120, minSites: 1, maxSites: 40 },
);
});
test("the bounds describe a range a user can actually satisfy", () => {
assert.ok(
SCHEDULE_BOUNDS.minMaxMinutes < SCHEDULE_BOUNDS.maxMaxMinutes,
"an empty minute range would disable every enrolment",
);
assert.ok(
SCHEDULE_BOUNDS.minSites >= 1,
"the bot browses declared sites only, so at least one is required",
);
assert.ok(SCHEDULE_BOUNDS.minSites <= SCHEDULE_BOUNDS.maxSites);
});
test("the enrolment form takes its bounds from here, not from its own literals", () => {
const source = readFileSync(
fileURLToPath(
new URL("../components/cookie-bot-enrol-dialog.tsx", import.meta.url),
),
"utf8",
);
assert.match(
source,
/from "@\/lib\/cookie-bot-limits"/,
"the dialog must import the shared bounds",
);
for (const name of [
"MIN_MAX_MINUTES",
"MAX_MAX_MINUTES",
"MIN_SITES",
"MAX_SITES",
]) {
assert.doesNotMatch(
source,
new RegExp(`const ${name}\\s*=\\s*\\d`),
`${name} must be derived from SCHEDULE_BOUNDS, not re-declared as a literal`,
);
}
});
+32
View File
@@ -0,0 +1,32 @@
/**
* The server's schedule bounds, mirrored for form validation.
*
* These numbers are NOT the client's to choose. They belong to
* `validateScheduleBody` and `normaliseSites` in donutbrowser-infra's
* `apps/backend/src/cookie-bot/cookie-bot.service.ts`, which refuses anything
* outside them with `COOKIE_BOT_INVALID_SCHEDULE` or `COOKIE_BOT_SITE_LIMIT`.
* They are mirrored here only so the enrolment form can refuse a value before
* it costs a round trip, and so the reason lands on the field rather than in a
* toast that names no field at all.
*
* Kept in a module of their own, with no imports, so `cookie-bot-limits.test.mjs`
* can pin them. A bound widened server-side (`max_minutes` to 180, sites capped
* at 25) then shows up as a failing assertion instead of as a form that blocks
* a legal value or accepts an illegal one.
*
* @see cookie-bot-limits.test.mjs the tripwire.
*/
export const SCHEDULE_BOUNDS = {
/** `MIN_MAX_MINUTES` in cookie-bot-schedule.ts. */
minMaxMinutes: 5,
/** `MAX_MAX_MINUTES` in cookie-bot-schedule.ts. */
maxMaxMinutes: 120,
/**
* v1 browses the user's declared sites and nothing else, so an enrolment
* with none is one the server cannot act on. `normaliseSites` rejects an
* empty list.
*/
minSites: 1,
/** `MAX_SITES` in cookie-bot-schedule.ts. */
maxSites: 40,
} as const;
+337
View File
@@ -0,0 +1,337 @@
import { invoke } from "@tauri-apps/api/core";
/**
* Cookie bot: overnight warming of a synced profile on a leased remote host.
*
* Nothing about HOW the bot browses is here. The schedule, the calendar maths,
* what a preset expands to, the site ordering, the dwell model and the pooled
* budget are all held server-side. This module sends the user's own scalars
* when, how long, which of their sites, which preset id and renders back what
* the server reports.
*/
/** Bit 0 = Monday, bit 6 = Sunday. */
export const COOKIE_BOT_DAY_BITS = [1, 2, 4, 8, 16, 32, 64] as const;
/** 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";
export interface CookieBotSchedule {
profile_id: string;
profile_name: string;
platform: string;
enabled: boolean;
/** Minutes past local midnight the run is anchored to. */
run_at_minute: number;
/** Bitmask of local weekdays, bit 0 = Monday. */
days_mask: number;
timezone: string;
/** Opaque server-issued preset id. */
preset: string;
max_minutes: number;
sites: string[];
jitter_seconds: number;
// The profile facts the desktop declared, echoed back on every read, so the
// UI can tell when the server's copy of a profile has gone stale.
sync_enabled: boolean;
encrypted_sync: boolean;
has_proxy: boolean;
touch_fingerprint: boolean;
sticky_exit: boolean;
/** When those facts were last refreshed. */
profile_state_at?: string | null;
/**
* Why tonight would be refused, or null. One of the run outcome codes.
*
* The server computes this on every read so a broken enrolment is visible the
* moment it breaks, rather than first announcing itself as a skipped run at
* 02:00.
*/
blocked_by?: string | null;
next_run_at?: string | null;
last_run_at?: string | null;
last_run_id?: string | null;
owner_user_id?: string | null;
owner_email?: string | null;
updated_at?: string | null;
}
/**
* What the desktop sends when enrolling or editing. `next_run_at` is absent by
* design: the server recomputes it and ignores any client value.
*/
export interface CookieBotScheduleInput {
profile_name: string;
platform: CookieBotPlatform;
enabled: boolean;
run_at_minute: number;
days_mask: number;
timezone: string;
preset: string;
max_minutes: number;
sites: string[];
jitter_seconds?: number;
}
export interface CookieBotScheduleList {
schedules: CookieBotSchedule[];
team_id?: string | null;
scope?: string | null;
}
/** A teammate's enrolment of the same profile. */
export interface CookieBotConflict {
user_id: string;
email: string;
run_at_minute: number;
timezone: string;
days_mask: number;
enabled: boolean;
/** The two enrolments share a weekday and fire within an hour. */
overlaps: boolean;
}
export interface CookieBotScheduleSaved {
schedule: CookieBotSchedule;
/** Repeated on an acknowledged write, so the warning can stay on screen. */
conflicts: CookieBotConflict[];
}
export interface CookieBotRun {
id: string;
profile_id: string;
profile_name?: string | null;
user_id?: string | null;
email?: string | null;
team_id?: string | null;
/** `schedule` or `manual`. */
trigger: string;
/**
* `pending` | `running` | `succeeded` | `partial` | `failed` | `skipped` |
* `cancelled`.
*/
status: string;
scheduled_for: string;
/** The jittered instant the run was allowed to start. */
dispatch_after?: string | null;
started_at?: string | null;
ended_at?: string | null;
/** The night's whole budget, which may span several browser sessions. */
max_minutes: number;
/** How many sessions this night is split into, and which one is running. */
chunks_total: number;
chunk_index: number;
sites_total: number;
sites_visited: number;
sites_failed: number;
consent_dismissed: number;
billed_seconds: number;
/** Why it ended the way it did, e.g. `profile_locked`, `no_capacity`. */
outcome_code?: string | null;
session_id?: string | null;
}
export interface CookieBotRunPage {
runs: CookieBotRun[];
/** Keyset cursor for the next page; null on the last one. */
next_before?: string | null;
}
export interface CookieBotRunStarted {
run: CookieBotRun;
session_id?: string | null;
}
/**
* A named intensity. The client learns only enough to label the choice and
* show its rough cost; what it expands to is the server's.
*/
export interface CookieBotPreset {
id: string;
typical_minutes?: number | null;
recommended: boolean;
/** Server-supplied English label, so a preset newer than this build still
* renders. Prefer a local `t()` key for a known id. */
name?: string | null;
description?: string | null;
}
export interface CookieBotPresetList {
presets: CookieBotPreset[];
default_preset?: string | null;
}
export interface RemoteHoursBreakdown {
interactive_hours: number;
bot_hours: number;
}
export interface RemoteHoursMember {
user_id: string;
email: string;
role?: string | null;
used_hours: number;
interactive_hours: number;
bot_hours: number;
}
/**
* The single pooled remote-hour budget. Bot and interactive sessions share it;
* the breakdown is reporting, never a sub-cap.
*/
export interface RemoteHoursQuota {
granted_hours: number;
remaining_hours: number;
used_hours: number;
period_start?: string | null;
period_end?: string | null;
/** `user` or `team`. */
scope?: string | null;
team_id?: string | null;
seats: number;
per_seat_hours: number;
breakdown?: RemoteHoursBreakdown | null;
members: RemoteHoursMember[];
}
export interface CookieBotUsageMember {
user_id: string;
email: string;
role?: string | null;
interactive_hours: number;
bot_hours: number;
used_hours: number;
sessions: number;
bot_runs: number;
bot_runs_failed: number;
}
export interface CookieBotUsageProfile {
profile_id: string;
profile_name?: string | null;
owner_email?: string | null;
bot_hours: number;
runs: number;
/** How many of those runs did not do what they were asked. */
runs_failed: number;
last_run_at?: string | null;
last_status?: string | null;
}
export interface CookieBotUsage {
/** `YYYY-MM`. */
period: string;
period_start?: string | null;
period_end?: string | null;
team_id?: string | null;
seats: number;
granted_hours: number;
used_hours: number;
remaining_hours: number;
members: CookieBotUsageMember[];
profiles: CookieBotUsageProfile[];
}
export function getCookieBotSchedules(
scope?: CookieBotScope,
): Promise<CookieBotScheduleList> {
return invoke<CookieBotScheduleList>("get_cookie_bot_schedules", { scope });
}
/** `null` means the profile is not enrolled, which is a state, not a failure. */
export function getCookieBotSchedule(
profileId: string,
): Promise<CookieBotSchedule | null> {
return invoke<CookieBotSchedule | null>("get_cookie_bot_schedule", {
profileId,
});
}
/**
* Create or replace an enrolment. A teammate's existing enrolment refuses the
* first write with `COOKIE_BOT_SCHEDULE_CONFLICT`; repeating it with
* `acknowledgeConflict` goes through.
*/
export function saveCookieBotSchedule(
profileId: string,
schedule: CookieBotScheduleInput,
acknowledgeConflict = false,
): Promise<CookieBotScheduleSaved> {
return invoke<CookieBotScheduleSaved>("save_cookie_bot_schedule", {
profileId,
schedule,
acknowledgeConflict,
});
}
/** `false` means there was nothing enrolled to remove. */
export function deleteCookieBotSchedule(profileId: string): Promise<boolean> {
return invoke<boolean>("delete_cookie_bot_schedule", { profileId });
}
/** Who else already warms this profile, without writing anything. */
export function checkCookieBotConflicts(
profileId: string,
options: {
runAtMinute?: number;
timezone?: string;
daysMask?: number;
} = {},
): Promise<CookieBotConflict[]> {
return invoke<CookieBotConflict[]>("check_cookie_bot_conflicts", {
profileId,
runAtMinute: options.runAtMinute,
timezone: options.timezone,
daysMask: options.daysMask,
});
}
export function getCookieBotRuns(
options: {
profileId?: string;
scope?: CookieBotScope;
limit?: number;
before?: string;
} = {},
): Promise<CookieBotRunPage> {
return invoke<CookieBotRunPage>("get_cookie_bot_runs", {
profileId: options.profileId,
scope: options.scope,
limit: options.limit,
before: options.before,
});
}
/** Start a run now. The preset and sites come from the stored enrolment. */
export function runCookieBotNow(
profileId: string,
maxMinutes?: number,
): Promise<CookieBotRunStarted> {
return invoke<CookieBotRunStarted>("run_cookie_bot_now", {
profileId,
maxMinutes,
});
}
export function cancelCookieBotRun(runId: string): Promise<CookieBotRun> {
return invoke<CookieBotRun>("cancel_cookie_bot_run", { runId });
}
export function getCookieBotPresets(): Promise<CookieBotPresetList> {
return invoke<CookieBotPresetList>("get_cookie_bot_presets");
}
export function getRemoteHoursQuota(): Promise<RemoteHoursQuota> {
return invoke<RemoteHoursQuota>("get_remote_hours_quota");
}
/** Per-member and per-profile spend for a calendar month (`YYYY-MM`). */
export function getCookieBotUsage(period?: string): Promise<CookieBotUsage> {
return invoke<CookieBotUsage>("get_cookie_bot_usage", { period });
}
+47 -1
View File
@@ -7,6 +7,7 @@ interface Capabilities {
crossOsFingerprints: boolean;
cloudBackup: boolean;
teamCollaboration: boolean;
cookieBot: boolean;
}
const NONE: Entitlements = {
@@ -15,8 +16,10 @@ const NONE: Entitlements = {
crossOsFingerprints: false,
cloudBackup: false,
teamCollaboration: false,
cookieBot: false,
profileLimit: 0,
requestsPerHour: 0,
remoteBrowserHours: 0,
};
// Mirror of PLAN_CAPABILITIES in apps/backend/src/plans/entitlements.ts. Keep in
@@ -27,24 +30,28 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
crossOsFingerprints: true,
cloudBackup: true,
teamCollaboration: false,
cookieBot: false,
},
pro: {
browserAutomation: true,
crossOsFingerprints: true,
cloudBackup: true,
teamCollaboration: false,
cookieBot: true,
},
team: {
browserAutomation: true,
crossOsFingerprints: true,
cloudBackup: true,
teamCollaboration: true,
cookieBot: true,
},
enterprise: {
browserAutomation: true,
crossOsFingerprints: true,
cloudBackup: true,
teamCollaboration: true,
cookieBot: true,
},
};
@@ -54,6 +61,7 @@ const DEFAULT_PAID: Capabilities = {
crossOsFingerprints: true,
cloudBackup: true,
teamCollaboration: false,
cookieBot: true,
};
/**
@@ -65,7 +73,21 @@ const DEFAULT_PAID: Capabilities = {
export function getEntitlements(
user: CloudUser | null | undefined,
): Entitlements {
if (user?.entitlements) return user.entitlements;
if (user?.entitlements) {
const server = user.entitlements;
// A backend (or a cached login) older than the cookie-bot release omits
// these two keys. Reading them as `undefined` would hide a paid feature
// from a paying customer with nothing logged anywhere, so resolve them
// here — the one place every caller already goes through. Cookie Bot is
// remote automation on leased hardware, so it tracks `browserAutomation`
// exactly; `remoteBrowserHours` stays 0 because the spendable figure is
// whatever `get_remote_hours_quota` reports, never a client guess.
return {
...server,
cookieBot: server.cookieBot ?? server.browserAutomation,
remoteBrowserHours: server.remoteBrowserHours ?? 0,
};
}
if (!user) return NONE;
const active =
@@ -80,7 +102,31 @@ export function getEntitlements(
crossOsFingerprints: caps.crossOsFingerprints,
cloudBackup: caps.cloudBackup,
teamCollaboration: caps.teamCollaboration,
cookieBot: caps.cookieBot,
profileLimit: user.profileLimit,
requestsPerHour: caps.browserAutomation ? DEFAULT_REQUESTS_PER_HOUR : 0,
remoteBrowserHours: 0,
};
}
/**
* Whether this user may enrol profiles in Cookie Bot. Every gate in the UI
* goes through here so a plan change is one edit, and so the Pro badge and the
* control it guards can never disagree.
*/
export function canUseCookieBot(user: CloudUser | null | undefined): boolean {
const entitlements = getEntitlements(user);
return entitlements.active && entitlements.cookieBot;
}
/**
* Only a team owner sees per-member attribution. An admin can change team
* settings but the pooled spend is the owner's bill.
*/
export function isTeamOwner(user: CloudUser | null | undefined): boolean {
return (
getEntitlements(user).teamCollaboration &&
user?.teamRole === "owner" &&
Boolean(user.teamId)
);
}
+143
View File
@@ -0,0 +1,143 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
/**
* Remote sessions: a profile opened on a leased Windows or macOS host.
*
* The desktop holds no part of the session's life. It asks the backend to
* start or stop one and is told what happened; the fleet, the two-hour cap,
* the profile lock and the billing all live server-side.
*/
/**
* `provisioning` -> `ready` -> `live` -> `closed`, plus `error` for a session
* that failed on the fleet.
*
* Kept open rather than a closed union: the state machine is the server's, and
* a state added there must render as itself instead of failing to decode.
*/
export type RemoteSessionPhase = string;
export interface RemoteSessionState {
session_id: string;
profile_id?: string | null;
platform?: string | null;
/** Named to match the server's `RemoteSessionView.state`. */
state: RemoteSessionPhase;
/** The relay is up, so the session can actually be driven. */
cdp_ready: boolean;
/** `interactive` or `cookie_bot`. */
kind?: string | null;
/** Set when the session belongs to a cookie-bot run. */
run_id?: string | null;
/** The team the hours are attributed to. */
team_id?: string | null;
started_at?: string | null;
/** When it finished. One timestamp, not a ready/closed pair. */
ended_at?: string | null;
/** Why it ended, e.g. `stopped_by_user`, `max_duration`. */
close_reason?: string | null;
/** What it has cost so far — a running figure while it is live. */
billed_seconds?: number | null;
}
export interface RemoteSessionEnded {
session_id: string;
status: string;
billed_seconds: number;
}
/**
* States a session cannot leave under its own steam.
*
* `error` is terminal just as `closed` is, and there is never a `closed` frame
* after one the server keeps a fleet-reported failure visible rather than
* flattening it into "finished". A consumer that only watched for `closed`
* would leave a failed session pinned in its live list for ever, and the
* profile would look busy until the app restarted.
*/
export function isSessionOver(session: RemoteSessionState): boolean {
return session.state === "closed" || session.state === "error";
}
/** Everything the caller owns, pushed once when the stream connects. */
export interface RemoteSessionSnapshot {
sessions: RemoteSessionState[];
}
/** Whether the desktop is currently receiving transitions. */
export interface RemoteSessionStreamStatus {
connected: boolean;
reason?: string | null;
}
/**
* Tauri event names. A transition arrives here rather than being polled for:
* `POST /api/remote-sessions` answers `provisioning` and nothing more, so
* without the stream the desktop is blind between launch and stop.
*/
export const REMOTE_SESSION_EVENTS = {
/** One session changed. Payload: `RemoteSessionState`. */
state: "remote-session-state",
/** Connect snapshot. Payload: `RemoteSessionSnapshot`. */
snapshot: "remote-session-snapshot",
/** Stream connectivity. Payload: `RemoteSessionStreamStatus`. */
stream: "remote-session-stream",
} as const;
export function listRemoteSessions(): Promise<RemoteSessionState[]> {
return invoke<RemoteSessionState[]>("list_remote_sessions");
}
export function getRemoteSession(
sessionId: string,
): Promise<RemoteSessionState> {
return invoke<RemoteSessionState>("get_remote_session", { sessionId });
}
export function stopRemoteSession(
sessionId: string,
): Promise<RemoteSessionEnded> {
return invoke<RemoteSessionEnded>("stop_remote_session", { sessionId });
}
/** Subscribe to transitions. Idempotent; call once the user is signed in. */
export function startRemoteSessionEvents(): Promise<void> {
return invoke<void>("start_remote_session_events");
}
/** Unsubscribe. Call on sign-out. */
export function stopRemoteSessionEvents(): Promise<void> {
return invoke<void>("stop_remote_session_events");
}
/** Whether the subscriber is alive, for a UI that mounted after it started. */
export function getRemoteSessionEventsStatus(): Promise<boolean> {
return invoke<boolean>("get_remote_session_events_status");
}
export function onRemoteSessionState(
handler: (session: RemoteSessionState) => void,
): Promise<UnlistenFn> {
return listen<RemoteSessionState>(REMOTE_SESSION_EVENTS.state, (event) =>
handler(event.payload),
);
}
export function onRemoteSessionSnapshot(
handler: (snapshot: RemoteSessionSnapshot) => void,
): Promise<UnlistenFn> {
return listen<RemoteSessionSnapshot>(
REMOTE_SESSION_EVENTS.snapshot,
(event) => handler(event.payload),
);
}
export function onRemoteSessionStream(
handler: (status: RemoteSessionStreamStatus) => void,
): Promise<UnlistenFn> {
return listen<RemoteSessionStreamStatus>(
REMOTE_SESSION_EVENTS.stream,
(event) => handler(event.payload),
);
}
+9
View File
@@ -36,6 +36,7 @@ export type ShortcutId =
| "goProxies"
| "goExtensions"
| "goGroups"
| "goCookieBot"
| "goIntegrations"
| "goAccount"
| "goSettings";
@@ -92,6 +93,14 @@ export const SHORTCUTS: ShortcutDef[] = [
key: "g",
mod: true,
},
{
// Mod+B: "bot". Every other letter in the navigation group was taken.
id: "goCookieBot",
labelKey: "shortcuts.goCookieBot",
group: "navigation",
key: "b",
mod: true,
},
{
id: "goIntegrations",
labelKey: "shortcuts.goIntegrations",
+61 -1
View File
@@ -1,3 +1,6 @@
import type { CookieBotSchedule } from "@/lib/cookie-bot";
import type { RemoteSessionState } from "@/lib/remote-sessions";
export interface ProxySettings {
proxy_type: string;
host: string;
@@ -94,10 +97,29 @@ export interface Entitlements {
crossOsFingerprints: boolean;
cloudBackup: boolean;
teamCollaboration: boolean;
/** Overnight profile warming on a leased remote host. */
cookieBot: boolean;
profileLimit: number;
requestsPerHour: number;
/**
* Per-seat monthly allowance for remote sessions. Reporting only: the
* spendable figure is whatever `get_remote_hours_quota` returns, because a
* team pools this across its seats and only the server knows the seat count.
*/
remoteBrowserHours: number;
}
/**
* What a backend older than the cookie-bot release actually sends. Read it
* through `getEntitlements()`, which fills the gap never off `CloudUser`
* directly, or a paying customer's Cookie Bot silently reads `false`.
*/
export type ServerEntitlements = Omit<
Entitlements,
"cookieBot" | "remoteBrowserHours"
> &
Partial<Pick<Entitlements, "cookieBot" | "remoteBrowserHours">>;
export interface CloudUser {
id: string;
email: string;
@@ -120,7 +142,45 @@ export interface CloudUser {
isPrimaryDevice?: boolean | null;
// Plan-derived capabilities. The desktop resolves this before handing CloudUser
// to the UI; optional to stay safe on older cached state.
entitlements?: Entitlements;
entitlements?: ServerEntitlements;
}
/**
* Cookie Bot and remote-session wire types. Defined next to the transport that
* owns them (`src/lib/cookie-bot.ts`, `src/lib/remote-sessions.ts`) and
* re-exported here so a component reads one module. Type-only, so nothing is
* pulled into the bundle.
*/
export type {
CookieBotConflict,
CookieBotPreset,
CookieBotPresetList,
CookieBotRun,
CookieBotRunPage,
CookieBotRunStarted,
CookieBotSchedule,
CookieBotScheduleInput,
CookieBotScheduleList,
CookieBotScheduleSaved,
CookieBotScope,
CookieBotUsage,
CookieBotUsageMember,
CookieBotUsageProfile,
RemoteHoursMember,
RemoteHoursQuota,
} from "@/lib/cookie-bot";
export type {
RemoteSessionPhase,
RemoteSessionSnapshot,
RemoteSessionState,
} from "@/lib/remote-sessions";
/** Where a profile stands with the bot, as one row of the profile table reads it. */
export interface ProfileBotState {
/** The stored enrolment, or null when the profile is not enrolled. */
schedule: CookieBotSchedule | null;
/** A remote session for this profile that has not closed yet. */
liveSession: RemoteSessionState | null;
}
export interface ProfileLockInfo {