mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-12 06:00:23 +02:00
feat: cookie bot
This commit is contained in:
+112
-1
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user