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
+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",