refactor: cleanup

This commit is contained in:
zhom
2026-08-06 14:39:53 -07:00
parent 39bbdcb547
commit f12a84e18f
49 changed files with 4555 additions and 377 deletions
+42 -1
View File
@@ -74,6 +74,11 @@ export type BackendErrorCode =
| "REMOTE_NO_CAPACITY"
| "REMOTE_NOT_ENTITLED"
| "REMOTE_INTERACTIVE_NOT_ENTITLED"
// The profile's exit only resolves on this computer, so a leased host cannot
// use it. Its own code rather than the Cookie Bot's twin: the two refusals
// name different features, and a user told their "Cookie Bot" needs a public
// proxy while they were opening a browser by hand cannot act on that.
| "REMOTE_REQUIRES_REMOTE_EXIT_NODE"
| "REMOTE_SESSION_REFUSED"
| "REMOTE_SESSION_NOT_FOUND"
| "REMOTE_SESSION_CONFLICT"
@@ -99,6 +104,11 @@ export type BackendErrorCode =
| "COOKIE_BOT_UNKNOWN_PLATFORM"
| "COOKIE_BOT_UNSUPPORTED_PLATFORM"
| "COOKIE_BOT_REQUIRES_EXIT_NODE"
// The profile HAS an exit, but only this machine can reach it (127.0.0.1, a
// LAN address, a `.local` name). Its own code because the fix is different:
// "attach a proxy" is unactionable advice for someone whose proxy is plainly
// attached.
| "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE"
// The server's own names for two refusals it throws from `putSchedule`,
// `updateProfileState` and `runNow`. `COOKIE_BOT_REQUIRES_PROXY` is the
// server-side twin of the local `COOKIE_BOT_REQUIRES_EXIT_NODE` precondition;
@@ -110,6 +120,8 @@ export type BackendErrorCode =
| "LAUNCH_CONSENT_EXPIRED"
| "VPN_WORKER_START_FAILED"
| "EXIT_PROBE_FAILED"
| "CAMOUFOX_REMOVED"
| "NO_E2E_PASSWORD_SET"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -289,8 +301,29 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.mcpAgentRemoveFailed", {
detail: parsed.params?.detail ?? "",
});
case "VLESS_CONFIG_INVALID":
// Donut supports exactly one VLESS shape (REALITY + XTLS Vision over TCP),
// so most rejections mean "your server is a kind we do not support", not
// "you mistyped". Name the unsupported part instead of implying a typo.
case "VLESS_CONFIG_INVALID": {
const reason = parsed.params?.reason;
const known = [
"security",
"flow",
"transport",
"encryption",
"headerType",
"fingerprint",
"sni",
"publicKey",
"scheme",
"parameter",
"malformed",
];
if (reason && known.includes(reason)) {
return t(`backendErrors.vlessUnsupported.${reason}`);
}
return t("backendErrors.vlessConfigInvalid");
}
case "XRAY_UNAVAILABLE":
return t("backendErrors.xrayUnavailable");
case "XRAY_UNSUPPORTED_OS":
@@ -317,6 +350,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
// runs every night is the confusing case this code exists to avoid.
case "REMOTE_INTERACTIVE_NOT_ENTITLED":
return t("backendErrors.remoteInteractiveNotEntitled");
case "REMOTE_REQUIRES_REMOTE_EXIT_NODE":
return t("backendErrors.remoteRequiresRemoteExitNode");
case "REMOTE_SESSION_REFUSED":
return t("backendErrors.remoteSessionRefused");
case "REMOTE_SESSION_NOT_FOUND":
@@ -389,6 +424,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
// resolve to the one sentence a user can act on.
case "COOKIE_BOT_REQUIRES_PROXY":
return t("backendErrors.cookieBotRequiresExitNode");
case "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE":
return t("backendErrors.cookieBotRequiresRemoteExitNode");
case "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED":
return t("backendErrors.cookieBotTouchFingerprintUnsupported");
// The launch gate's block. The dialog renders the mismatch detail from
@@ -404,6 +441,10 @@ export function translateBackendError(t: TFunction, err: unknown): string {
});
case "EXIT_PROBE_FAILED":
return t("backendErrors.exitProbeFailed");
case "CAMOUFOX_REMOVED":
return t("backendErrors.camoufoxRemoved");
case "NO_E2E_PASSWORD_SET":
return t("backendErrors.noE2ePasswordSet");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+163 -2
View File
@@ -13,24 +13,70 @@ import { invoke } from "@tauri-apps/api/core";
/** Bit 0 = Monday, bit 6 = Sunday. */
export const COOKIE_BOT_DAY_BITS = [1, 2, 4, 8, 16, 32, 64] as const;
/**
* What marks a `template_id` as one of the USER's own rather than a curated one.
*
* The two kinds share one field and behave in opposite ways — a curated
* template's URLs are server-owned and expanded per profile at dispatch, a
* user's are copied onto the enrolment when it is saved. An id read as the wrong
* kind is a schedule that browses the wrong list, so every question about which
* kind an id is goes through the helper below rather than a `startsWith` at the
* call site.
*/
export const COOKIE_BOT_USER_TEMPLATE_PREFIX = "user:";
export function isUserTemplateId(id: string | null | undefined): boolean {
return (
typeof id === "string" && id.startsWith(COOKIE_BOT_USER_TEMPLATE_PREFIX)
);
}
/** Hosts the fleet can lease. Linux is refused at enrolment. */
export type CookieBotPlatform = "windows" | "macos";
/** `mine` shows the caller's enrolments, `team` the whole team's. */
export type CookieBotScope = "mine" | "team";
/** One time-of-day an enrolment fires, on a set of local weekdays. */
export interface CookieBotSlot {
/** Bitmask of local weekdays, bit 0 = Monday. At least one bit set. */
days_mask: number;
/** Minutes past local midnight, in the schedule's timezone. */
run_at_minute: number;
}
export interface CookieBotSchedule {
profile_id: string;
profile_name: string;
platform: string;
enabled: boolean;
/** Minutes past local midnight the run is anchored to. */
/**
* Minutes past local midnight the FIRST slot is anchored to. The server
* mirrors `slots[0]` onto this pair on every write.
*/
run_at_minute: number;
/** Bitmask of local weekdays, bit 0 = Monday. */
/** The first slot's weekdays, bit 0 = Monday. See `run_at_minute`. */
days_mask: number;
/**
* Every time-of-day this enrolment fires.
*
* Optional because a server that predates multi-slot scheduling sends only
* the mirrored pair above. Read it through `scheduleSlots()` rather than
* directly, so the fallback happens in one place instead of at each renderer
* — an empty list here means "this server did not say", never "never fires".
*/
slots?: CookieBotSlot[];
timezone: string;
/** Opaque server-issued preset id. */
preset: string;
/**
* The template the site list came from, or null for the user's own list.
*
* A built-in id means `sites` is EMPTY on purpose: those URLs are curated
* server-side and deliberately never sent to a client. A `user:<uuid>` id is
* provenance — the sites were copied onto the enrolment and are present.
*/
template_id?: string | null;
max_minutes: number;
sites: string[];
jitter_seconds: number;
@@ -70,10 +116,23 @@ export interface CookieBotScheduleInput {
profile_name: string;
platform: CookieBotPlatform;
enabled: boolean;
/** Mirror of `slots[0]`, for a server that predates multi-slot scheduling. */
run_at_minute: number;
/** Mirror of `slots[0]`. See `run_at_minute`. */
days_mask: number;
/**
* The whole calendar. Omit it — never send an empty array — for "one slot,
* from the pair above": the server refuses an empty list, because a schedule
* that fires at no time is a mistake rather than a way to pause one.
*/
slots?: CookieBotSlot[];
timezone: string;
preset: string;
/**
* A browsing template instead of a typed site list. Mutually exclusive with a
* non-empty `sites`: the server refuses a write carrying both.
*/
template_id?: string;
max_minutes: number;
sites: string[];
jitter_seconds?: number;
@@ -162,9 +221,70 @@ export interface CookieBotPreset {
description?: string | null;
}
/**
* A curated browsing template: a named answer to "what is this profile for",
* picked INSTEAD of typing a site list.
*
* Carries a count and never the URLs. That is the product working as designed —
* the pool is curated server-side and each profile draws its own sample from it,
* so the template never becomes one recognisable fleet-wide set of visits. Any
* copy describing this must say so as the feature it is.
*/
export interface CookieBotTemplate {
id: string;
/** How many sites this template browses. Not which. */
site_count: number;
/** Server-supplied English fallbacks, for a template newer than this build. */
name?: string | null;
description?: string | null;
}
/**
* The server's own form bounds, when it publishes them.
*
* Every field is optional: a deployment that predates this object sends none of
* them, and treating a missing bound as `0` would refuse every value the form
* can produce. `SCHEDULE_BOUNDS` in `cookie-bot-limits.ts` is the fallback.
*/
export interface CookieBotLimits {
min_minutes?: number | null;
max_minutes?: number | null;
min_sites?: number | null;
max_sites?: number | null;
/** Most entries a calendar may carry. */
max_slots?: number | null;
/** Longest name a saved site list may be given. */
max_template_name_length?: number | null;
}
export interface CookieBotPresetList {
presets: CookieBotPreset[];
default_preset?: string | null;
/**
* The curated templates on offer. Served with the presets so one added
* server-side becomes selectable without a desktop release.
*/
templates?: CookieBotTemplate[];
limits?: CookieBotLimits | null;
}
/**
* One of the caller's OWN saved site lists.
*
* Carries its URLs, unlike {@link CookieBotTemplate}: they are the user's own
* and there is nothing to withhold. Applying one COPIES the sites onto the
* enrolment, so editing a list later does not silently change what an existing
* schedule browses.
*/
export interface CookieBotUserTemplate {
/**
* Already prefixed `user:<uuid>` — the value `template_id` takes verbatim.
* Nothing on this side assembles that convention.
*/
id: string;
name: string;
sites: string[];
updated_at?: string | null;
}
export interface RemoteHoursBreakdown {
@@ -327,6 +447,47 @@ export function getCookieBotPresets(): Promise<CookieBotPresetList> {
return invoke<CookieBotPresetList>("get_cookie_bot_presets");
}
/** Every site list this user has saved, most recently edited first. */
export function getCookieBotUserTemplates(): Promise<CookieBotUserTemplate[]> {
return invoke<CookieBotUserTemplate[]>("get_cookie_bot_user_templates");
}
/** Save the current site list under a name. */
export function createCookieBotUserTemplate(
name: string,
sites: string[],
): Promise<CookieBotUserTemplate> {
return invoke<CookieBotUserTemplate>("create_cookie_bot_user_template", {
name,
sites,
});
}
/**
* Rename a saved list, replace its sites, or both.
*
* Send only what changed. A rename that also carried the site list would
* silently revert an edit made to it from another device in between.
*/
export function updateCookieBotUserTemplate(
id: string,
changes: { name?: string; sites?: string[] },
): Promise<CookieBotUserTemplate> {
return invoke<CookieBotUserTemplate>("update_cookie_bot_user_template", {
id,
name: changes.name,
sites: changes.sites,
});
}
/**
* Delete a saved list. `false` means there was nothing left to delete, which is
* a success — enrolments that used it keep the sites they copied either way.
*/
export function deleteCookieBotUserTemplate(id: string): Promise<boolean> {
return invoke<boolean>("delete_cookie_bot_user_template", { id });
}
export function getRemoteHoursQuota(): Promise<RemoteHoursQuota> {
return invoke<RemoteHoursQuota>("get_remote_hours_quota");
}
+116
View File
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_DECORATION_LAYOUT,
parseDecorationLayout,
} from "./window-decorations.ts";
/**
* The app draws its own titlebar on Linux, so it owns the window controls —
* and where they go is a desktop-wide user preference. This parser is the only
* thing standing between that preference and the buttons we render, and only
* GNOME can be exercised on the machine this was written on, so KDE's real
* layout strings are pinned here instead.
*/
test("GNOME's default puts every control on the right", () => {
assert.deepEqual(parseDecorationLayout(":minimize,maximize,close"), {
left: [],
right: ["minimize", "maximize", "close"],
});
});
test("a left-hand layout is honored", () => {
// GNOME users who prefer macOS ordering set exactly this.
assert.deepEqual(parseDecorationLayout("close,minimize,maximize:"), {
left: ["close", "minimize", "maximize"],
right: [],
});
});
test("controls can be split across both sides", () => {
assert.deepEqual(parseDecorationLayout("close:minimize,maximize"), {
left: ["close"],
right: ["minimize", "maximize"],
});
});
test("GTK's own default drops the appmenu it cannot draw", () => {
assert.deepEqual(parseDecorationLayout("appmenu:close"), {
left: [],
right: ["close"],
});
});
test("non-button tokens are ignored rather than rendered", () => {
// `icon`, `menu`, `appmenu` and `spacer` are all legal GTK tokens for things
// this titlebar does not draw.
assert.deepEqual(parseDecorationLayout("icon,menu:spacer,close"), {
left: [],
right: ["close"],
});
});
test("KDE's extra decoration buttons are ignored", () => {
// KWin offers buttons GTK has no concept of. kde-gtk-config maps what it can
// and may pass these through; rendering an unknown box would be worse than
// dropping it, which is what GTK itself does.
assert.deepEqual(
parseDecorationLayout(
"menu,applicationmenu:shade,keepabove,keepbelow,help,minimize,maximize,close",
),
{ left: [], right: ["minimize", "maximize", "close"] },
);
});
test("a duplicated control is rendered once", () => {
assert.deepEqual(parseDecorationLayout("close:close,minimize"), {
left: ["close"],
right: ["minimize"],
});
});
test("whitespace and capitalization are tolerated", () => {
assert.deepEqual(parseDecorationLayout(" : Minimize , CLOSE "), {
left: [],
right: ["minimize", "close"],
});
});
test("a string with no colon is entirely the left side, as GTK reads it", () => {
// `g_strsplit(layout, ":", 2)` leaves the right-hand token NULL, so GTK puts
// every button on the left. No mainstream desktop emits this, but matching
// GTK is the only defensible reading.
assert.deepEqual(parseDecorationLayout("minimize,close"), {
left: ["minimize", "close"],
right: [],
});
});
test("only the first colon splits the sides", () => {
// GTK's split has a limit of 2, so the second colon is not a separator: the
// right side becomes the single token "minimize:maximize", which matches no
// button name and is dropped — exactly as GTK drops it.
assert.deepEqual(parseDecorationLayout("close:minimize:maximize"), {
left: ["close"],
right: [],
});
});
test("missing, empty and unusable layouts fall back to the default", () => {
const fallback = { left: [], right: ["minimize", "maximize", "close"] };
for (const input of [null, undefined, "", " "]) {
assert.deepEqual(parseDecorationLayout(input), fallback, `input: ${input}`);
}
// A layout naming only buttons we cannot draw would otherwise leave the user
// with no way to close the window.
assert.deepEqual(parseDecorationLayout("appmenu:spacer"), fallback);
assert.deepEqual(parseDecorationLayout(":"), fallback);
});
test("the documented default parses to the default", () => {
assert.deepEqual(parseDecorationLayout(DEFAULT_DECORATION_LAYOUT), {
left: [],
right: ["minimize", "maximize", "close"],
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* Parsing for the desktop's titlebar button layout on Linux.
*
* The backend reads `GtkSettings::gtk-decoration-layout`, which GNOME populates
* from `org.gnome.desktop.wm.preferences button-layout` and KDE populates via
* `kde-gtk-config` from KWin's decoration settings. The grammar is the one GTK
* itself parses: a comma-separated list of button names for the left side, a
* `:`, then the list for the right side. Either side may be empty.
*
* ":minimize,maximize,close" GNOME default — everything on the right
* "close,minimize,maximize:" macOS-like — everything on the left
* "appmenu:close" upstream GTK default
*/
/** What the backend reports about this window's decorations. */
export interface WindowDecorationsInfo {
/** True when the app owns the titlebar and must draw controls and edges. */
client_side: boolean;
/** The desktop's button layout; only meaningful when `client_side`. */
layout: string | null;
}
/** Controls this app can actually draw. */
export type WindowControl = "minimize" | "maximize" | "close";
export interface DecorationLayout {
left: WindowControl[];
right: WindowControl[];
}
/** What an unconfigured GNOME or KDE session shows. */
export const DEFAULT_DECORATION_LAYOUT = ":minimize,maximize,close";
const CONTROLS: WindowControl[] = ["minimize", "maximize", "close"];
function parseSide(side: string, seen: Set<WindowControl>): WindowControl[] {
const out: WindowControl[] = [];
for (const raw of side.split(",")) {
const name = raw.trim().toLowerCase();
// Everything else a desktop can put here is deliberately dropped rather
// than rendered as an unknown box: `icon`, `menu`, `appmenu` and `spacer`
// from GTK, plus KDE's extras (`shade`, `above`/`keepabove`,
// `below`/`keepbelow`, `help`, `applicationmenu`, `ontop`). Silently
// ignoring an unrecognized token is also what GTK does.
if (!CONTROLS.includes(name as WindowControl)) {
continue;
}
const control = name as WindowControl;
// A desktop could list the same button on both sides; the shared `seen` set
// means the first occurrence wins and it is never drawn twice.
if (seen.has(control)) {
continue;
}
seen.add(control);
out.push(control);
}
return out;
}
/**
* Split a layout string into the buttons to draw on each side.
*
* Falls back to the GNOME/KDE default whenever the string is missing, empty, or
* names no button this app can draw — a titlebar with no way to close the
* window would be far worse than one that ignores an exotic preference.
*/
export function parseDecorationLayout(
layout: string | null | undefined,
): DecorationLayout {
const source = layout?.trim() ? layout : DEFAULT_DECORATION_LAYOUT;
// GTK splits on the FIRST colon only, and a string with no colon at all is
// entirely the left side (`g_strsplit(layout, ":", 2)` leaves the right
// token NULL). Matching that exactly beats inventing a friendlier rule.
const split = source.indexOf(":");
const leftRaw = split === -1 ? source : source.slice(0, split);
const rightRaw = split === -1 ? "" : source.slice(split + 1);
const seen = new Set<WindowControl>();
const left = parseSide(leftRaw, seen);
const right = parseSide(rightRaw, seen);
if (left.length === 0 && right.length === 0) {
return { left: [], right: [...CONTROLS] };
}
return { left, right };
}