mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-11 21:50:24 +02:00
refactor: cleanup
This commit is contained in:
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user