547 lines
20 KiB
TypeScript
547 lines
20 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { existsSync, statSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { MAX_DESKTOP_DISPLAY, screenPorts } from "@rakazo/core/node/desktop-runtime";
|
|
import type Docker from "dockerode";
|
|
|
|
export const COMPUTER_IMAGE = process.env.RAKAZO_COMPUTER_IMAGE ?? "rakazo/computer:local";
|
|
export const COMPUTER_UID = 1000;
|
|
export const COMPUTER_GID = 1000;
|
|
export const COMPUTER_USER = `${COMPUTER_UID}:${COMPUTER_GID}`;
|
|
export const COMPUTER_PATH =
|
|
"/usr/local/lib/rakazo/bin:/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
|
|
export { screenPorts };
|
|
export const COMPUTER_CONTROL_PORT = 7070;
|
|
export const SCREEN_HOST = process.env.SANDBOX_SCREEN_HOST ?? "127.0.0.1";
|
|
export type ScreenNetworkMode = "published" | "internal" | "isolated";
|
|
|
|
export function resolveTeamScreenLimit(value = process.env.SANDBOX_TEAM_SCREEN_LIMIT): number {
|
|
if (value === undefined || value.trim() === "" || isUnlimited(value)) return MAX_DESKTOP_DISPLAY;
|
|
const limit = Number(value);
|
|
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
throw new Error(
|
|
"SANDBOX_TEAM_SCREEN_LIMIT must be a positive integer, or 0 for no configured cap",
|
|
);
|
|
}
|
|
return Math.min(limit, MAX_DESKTOP_DISPLAY);
|
|
}
|
|
|
|
export function resolveSpaceComputerLimit(
|
|
value = process.env.SANDBOX_MAX_COMPUTERS_PER_SPACE,
|
|
): number {
|
|
if (value === undefined || value.trim() === "" || isUnlimited(value)) return 0;
|
|
const limit = Number(value);
|
|
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
throw new Error(
|
|
"SANDBOX_MAX_COMPUTERS_PER_SPACE must be a positive integer, or 0 for no configured cap",
|
|
);
|
|
}
|
|
return limit;
|
|
}
|
|
|
|
/**
|
|
* Resource ceilings for a bot computer.
|
|
*
|
|
* A computer runs Xvfb, a window manager and a full Chromium on behalf of an
|
|
* agent that decides for itself what to open. #343 gave these containers a
|
|
* pids ceiling, but Memory and NanoCpus are still unset, so one runaway page is
|
|
* a host-wide memory and CPU event that takes every other bot and the Rakazo
|
|
* services down with it. Every service in docker-compose.prod.yml already
|
|
* carries mem_limit; this applies the same discipline to the containers that
|
|
* actually run untrusted page content.
|
|
*
|
|
* Defaults are a starting point for the Docker computer topology: generous enough
|
|
* for real browsing, small enough that one computer cannot starve the host or
|
|
* sibling bots. The pids default is #343's existing 2048, unchanged. Set any of
|
|
* these to "0", "none" or "unlimited" to opt out.
|
|
*/
|
|
const DEFAULT_COMPUTER_MEMORY = "2g";
|
|
const DEFAULT_COMPUTER_CPUS = "2";
|
|
const DEFAULT_COMPUTER_PIDS_LIMIT = "2048";
|
|
/** The daemon refuses HostConfig.Memory below this at container creation. */
|
|
const MIN_DOCKER_MEMORY_BYTES = 6 * 1024 ** 2;
|
|
|
|
const MEMORY_UNITS: Record<string, number> = {
|
|
b: 1,
|
|
k: 1024,
|
|
m: 1024 ** 2,
|
|
g: 1024 ** 3,
|
|
};
|
|
|
|
function isUnlimited(raw: string): boolean {
|
|
const value = raw.trim().toLowerCase();
|
|
return value === "0" || value === "unlimited" || value === "none";
|
|
}
|
|
|
|
/** Bytes from a docker-style size string ("2g", "1536m", "1073741824"). */
|
|
export function parseMemoryBytes(name: string, raw: string): number {
|
|
if (isUnlimited(raw)) return 0;
|
|
const match = /^(\d+(?:\.\d+)?)\s*([bkmg])?b?$/i.exec(raw.trim());
|
|
if (!match) {
|
|
throw new Error(`${name} must be a size like "2g", "1536m" or a byte count, received "${raw}"`);
|
|
}
|
|
const scale = MEMORY_UNITS[(match[2] ?? "b").toLowerCase()] ?? 1;
|
|
const bytes = Math.floor(Number(match[1]) * scale);
|
|
if (!Number.isSafeInteger(bytes) || bytes <= 0) {
|
|
throw new Error(`${name} must resolve to a positive byte count, received "${raw}"`);
|
|
}
|
|
// The daemon rejects a limit under 6 MiB at container creation. Catching it here turns a
|
|
// per-bot 500 at the first `POST /computers` into a startup failure that names the variable.
|
|
if (bytes < MIN_DOCKER_MEMORY_BYTES) {
|
|
throw new Error(`${name} must be at least 6m, Docker's minimum, received "${raw}"`);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
/** Docker NanoCpus (1e9 per core) from a CPU count like "1.5". */
|
|
export function parseNanoCpus(name: string, raw: string): number {
|
|
if (isUnlimited(raw)) return 0;
|
|
const value = Number(raw.trim());
|
|
// Tiny positives floor to 0 nanocpus (Docker reads as unlimited). Huge values leave the
|
|
// safe-integer range or become Infinity. Validate the converted number either way.
|
|
const nanoCpus = Math.floor(value * 1e9);
|
|
if (!Number.isSafeInteger(nanoCpus) || nanoCpus <= 0) {
|
|
throw new Error(`${name} must be a positive number of CPUs, received "${raw}"`);
|
|
}
|
|
return nanoCpus;
|
|
}
|
|
|
|
function parsePidsLimit(name: string, raw: string): number {
|
|
if (isUnlimited(raw)) return 0;
|
|
const value = Number(raw.trim());
|
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
throw new Error(`${name} must be a positive integer, received "${raw}"`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** An unset or blank variable (as .env.example ships these) means the default. */
|
|
function limitSetting(name: string, fallback: string): string {
|
|
const value = process.env[name];
|
|
return value?.trim() ? value : fallback;
|
|
}
|
|
|
|
/** The host resource ceilings applied to every bot computer. */
|
|
export function computerResourceLimits() {
|
|
const memoryBytes = parseMemoryBytes(
|
|
"RAKAZO_COMPUTER_MEMORY",
|
|
limitSetting("RAKAZO_COMPUTER_MEMORY", DEFAULT_COMPUTER_MEMORY),
|
|
);
|
|
const nanoCpus = parseNanoCpus(
|
|
"RAKAZO_COMPUTER_CPUS",
|
|
limitSetting("RAKAZO_COMPUTER_CPUS", DEFAULT_COMPUTER_CPUS),
|
|
);
|
|
const pidsLimit = parsePidsLimit(
|
|
"RAKAZO_COMPUTER_PIDS_LIMIT",
|
|
limitSetting("RAKAZO_COMPUTER_PIDS_LIMIT", DEFAULT_COMPUTER_PIDS_LIMIT),
|
|
);
|
|
return {
|
|
// Memory and MemorySwap are set together: leaving MemorySwap unset lets the
|
|
// container swap to twice Memory, so the ceiling would not hold.
|
|
Memory: memoryBytes,
|
|
MemorySwap: memoryBytes,
|
|
NanoCpus: nanoCpus,
|
|
PidsLimit: pidsLimit,
|
|
};
|
|
}
|
|
|
|
export function resolveScreenNetworkMode(value: string | undefined): ScreenNetworkMode {
|
|
if (!value || value === "published") return "published";
|
|
if (value === "internal" || value === "isolated") return value;
|
|
throw new Error(`Unsupported SANDBOX_SCREEN_NETWORK value: ${value}`);
|
|
}
|
|
|
|
export function hostComputerUser(uid = process.getuid?.(), gid = process.getgid?.()): string {
|
|
if (uid === undefined || gid === undefined || uid === 0) return COMPUTER_USER;
|
|
return `${uid}:${gid}`;
|
|
}
|
|
|
|
export function computerPortBindings(publishControlPort = false) {
|
|
const ExposedPorts: Record<string, object> = {};
|
|
const PortBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> = {};
|
|
const port = `${screenPorts(0).viewPort}/tcp`;
|
|
ExposedPorts[port] = {};
|
|
PortBindings[port] = [{ HostIp: "127.0.0.1", HostPort: "0" }];
|
|
// Host-run Docker Desktop supervisors need an opt-in loopback mapping.
|
|
// Otherwise control stays unpublished on the container network.
|
|
if (publishControlPort) {
|
|
ExposedPorts[`${COMPUTER_CONTROL_PORT}/tcp`] = {};
|
|
PortBindings[`${COMPUTER_CONTROL_PORT}/tcp`] = [{ HostIp: "127.0.0.1", HostPort: "0" }];
|
|
}
|
|
return { ExposedPorts, PortBindings };
|
|
}
|
|
|
|
export function computerHomeStorage(
|
|
serviceHomePath: string,
|
|
dataDir: string,
|
|
info: Docker.ContainerInspectInfo | undefined,
|
|
): { homePath: string; homeVolume?: { name: string; subpath: string } } {
|
|
const relative = path.relative(dataDir, serviceHomePath);
|
|
if (
|
|
!relative ||
|
|
relative === ".." ||
|
|
relative.startsWith(`..${path.sep}`) ||
|
|
path.isAbsolute(relative)
|
|
) {
|
|
throw new Error("computer home must be inside the data directory");
|
|
}
|
|
const mount = info?.Mounts.find((entry) => entry.Destination === dataDir);
|
|
if (mount?.Type === "volume") {
|
|
if (!mount.Name) throw new Error("computer data volume has no name");
|
|
return { homePath: serviceHomePath, homeVolume: { name: mount.Name, subpath: relative } };
|
|
}
|
|
return { homePath: mount?.Source ? path.join(mount.Source, relative) : serviceHomePath };
|
|
}
|
|
|
|
export function assertVolumeSubpathSupport(apiVersion: string) {
|
|
const match = /^(\d+)\.(\d+)$/.exec(apiVersion);
|
|
if (!match || Number(match[1]) < 1 || (Number(match[1]) === 1 && Number(match[2]) < 45)) {
|
|
throw new Error("Docker Engine 26+ (API 1.45+) is required for bot home volume subpaths");
|
|
}
|
|
}
|
|
|
|
export function homeVolumeMatches(
|
|
mounts: Docker.ContainerInspectInfo["HostConfig"]["Mounts"],
|
|
volume: { name: string; subpath: string },
|
|
) {
|
|
return (
|
|
mounts?.some(
|
|
(mount) =>
|
|
mount.Target === "/home/rakazo" &&
|
|
mount.Type === "volume" &&
|
|
mount.Source === volume.name &&
|
|
mount.VolumeOptions?.Subpath === volume.subpath &&
|
|
!mount.ReadOnly,
|
|
) ?? false
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validates a host folder before bind-mounting it into a project computer.
|
|
* When the supervisor itself runs inside a container, host workspace folders are
|
|
* validated by the API on the host and by the Docker daemon when mounting.
|
|
*/
|
|
export function assertProjectWorkspaceBindPath(
|
|
hostPath: string,
|
|
inContainer = existsSync("/.dockerenv"),
|
|
): string {
|
|
const resolved = path.resolve(hostPath);
|
|
if (inContainer) return resolved;
|
|
let stat: ReturnType<typeof statSync>;
|
|
try {
|
|
stat = statSync(resolved);
|
|
} catch {
|
|
throw new Error("Project workspace path does not exist on the host.");
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
throw new Error("Project workspace path must be a directory on the host.");
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
export interface ComputerCreateInput {
|
|
name: string;
|
|
image: string;
|
|
botId: string;
|
|
spaceId: string;
|
|
homePath: string;
|
|
homeVolume?: { name: string; subpath: string };
|
|
/** Host path to bind read-write at /home/rakazo/project (deployment owner project spaces). */
|
|
projectWorkspacePath?: string;
|
|
user?: string;
|
|
controlToken?: string;
|
|
networkMode?: string;
|
|
publishControlPort?: boolean;
|
|
/** Host directory holding the GitHub broker socket, mounted read-only into the computer. */
|
|
githubBrokerSocketDir?: string;
|
|
}
|
|
|
|
/** Where computers find the GitHub broker socket; the git shim prefers it over the network. */
|
|
export const GITHUB_BROKER_SOCKET_MOUNT = "/run/rakazo-github-broker";
|
|
|
|
/** Computers created before the socket existed lack the mount and must be recreated to get it. */
|
|
export function githubBrokerSocketMounted(
|
|
mounts: Docker.ContainerInspectInfo["Mounts"] | undefined,
|
|
) {
|
|
return mounts?.some((mount) => mount.Destination === GITHUB_BROKER_SOCKET_MOUNT) ?? false;
|
|
}
|
|
|
|
interface PointerInput {
|
|
kind: "pointer";
|
|
x: number;
|
|
y: number;
|
|
button?: "left" | "right";
|
|
type: "move" | "down" | "up" | "click";
|
|
}
|
|
|
|
export type SandboxInput =
|
|
| { kind: "key"; key: string; modifiers?: string[] }
|
|
| PointerInput
|
|
| { kind: "clipboard"; text: string };
|
|
|
|
function computerVolumeMounts(input: ComputerCreateInput): Docker.MountSettings[] {
|
|
const mounts: Docker.MountSettings[] = [];
|
|
if (input.homeVolume) {
|
|
mounts.push({
|
|
Type: "volume",
|
|
Source: input.homeVolume.name,
|
|
Target: "/home/rakazo",
|
|
VolumeOptions: {
|
|
NoCopy: true,
|
|
Subpath: input.homeVolume.subpath,
|
|
} as Docker.MountSettings["VolumeOptions"],
|
|
});
|
|
}
|
|
if (input.projectWorkspacePath) {
|
|
mounts.push({
|
|
Type: "bind",
|
|
Source: input.projectWorkspacePath,
|
|
Target: "/home/rakazo/project",
|
|
ReadOnly: false,
|
|
});
|
|
}
|
|
if (input.githubBrokerSocketDir) {
|
|
mounts.push({
|
|
Type: "bind",
|
|
Source: input.githubBrokerSocketDir,
|
|
Target: GITHUB_BROKER_SOCKET_MOUNT,
|
|
ReadOnly: true,
|
|
});
|
|
}
|
|
return mounts;
|
|
}
|
|
|
|
export function containerCreateOptions(input: ComputerCreateInput) {
|
|
const ports = computerPortBindings(input.publishControlPort);
|
|
const volumeMounts = computerVolumeMounts(input);
|
|
return {
|
|
Image: input.image,
|
|
name: input.name,
|
|
User: input.user ?? COMPUTER_USER,
|
|
Tty: true,
|
|
Env: [
|
|
"DISPLAY=:1",
|
|
"HOME=/home/rakazo",
|
|
`PATH=${COMPUTER_PATH}`,
|
|
...(process.env.SANDBOX_GITHUB_BROKER_URL
|
|
? [`RAKAZO_GITHUB_BROKER_URL=${process.env.SANDBOX_GITHUB_BROKER_URL.replace(/\/$/, "")}`]
|
|
: []),
|
|
"NPM_CONFIG_PREFIX=/home/rakazo/.local",
|
|
"PIP_USER=1",
|
|
...(input.controlToken ? [`RAKAZO_COMPUTER_CONTROL_TOKEN=${input.controlToken}`] : []),
|
|
],
|
|
Labels: {
|
|
"rakazo.managed": "true",
|
|
"rakazo.botId": input.botId,
|
|
"rakazo.spaceId": input.spaceId,
|
|
},
|
|
ExposedPorts: ports.ExposedPorts,
|
|
HostConfig: {
|
|
...(input.homeVolume || input.projectWorkspacePath
|
|
? {
|
|
Binds: undefined,
|
|
Mounts: volumeMounts,
|
|
}
|
|
: {
|
|
Binds: [
|
|
`${input.homePath}:/home/rakazo`,
|
|
...(input.projectWorkspacePath
|
|
? [`${input.projectWorkspacePath}:/home/rakazo/project`]
|
|
: []),
|
|
...(input.githubBrokerSocketDir
|
|
? [`${input.githubBrokerSocketDir}:${GITHUB_BROKER_SOCKET_MOUNT}:ro`]
|
|
: []),
|
|
],
|
|
Mounts: undefined,
|
|
}),
|
|
PortBindings: ports.PortBindings,
|
|
ShmSize: 256 * 1024 * 1024,
|
|
CapDrop: ["ALL"],
|
|
SecurityOpt: ["no-new-privileges:true"],
|
|
...computerResourceLimits(),
|
|
ReadonlyPaths: ["/usr/share/novnc"],
|
|
AutoRemove: false,
|
|
// Linux has no host.docker.internal by default; the GitHub broker is reached through it.
|
|
ExtraHosts: ["host.docker.internal:host-gateway"],
|
|
NetworkMode: input.networkMode ?? "bridge",
|
|
},
|
|
WorkingDir: "/home/rakazo",
|
|
};
|
|
}
|
|
|
|
export function sanitizeIdentifier(botId: string) {
|
|
const safe = botId.replace(/[^a-zA-Z0-9_.-]/g, "").slice(0, 40);
|
|
return safe || "box";
|
|
}
|
|
|
|
export function containerNameFor(botId: string) {
|
|
return `rakazo-bot-${sanitizeIdentifier(botId)}`;
|
|
}
|
|
|
|
export function computerNetworkNameFor(botId: string) {
|
|
// Keep distinct botIds on distinct networks even when sanitization collapses
|
|
// characters (e.g. "a/b" and "ab"). Do not change containerNameFor — that
|
|
// name must stay stable so an existing computer can resume.
|
|
const hash = createHash("sha256").update(botId).digest("hex").slice(0, 32);
|
|
return `rakazo-computer-${sanitizeIdentifier(botId).slice(0, 32)}-${hash}`;
|
|
}
|
|
|
|
/** Current and prior network names used by this PR, for delete cleanup. */
|
|
export function computerNetworkNamesForCleanup(botId: string) {
|
|
const safe = sanitizeIdentifier(botId);
|
|
const digest = createHash("sha256").update(botId).digest("hex");
|
|
return [
|
|
computerNetworkNameFor(botId),
|
|
`rakazo-computer-${safe}`,
|
|
`rakazo-computer-${safe.slice(0, 32)}-${digest.slice(0, 8)}`,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Legacy unsalted network names can collide across botIds. Only remove such a
|
|
* network when no other bot's container is still attached.
|
|
*/
|
|
export function legacyNetworkOwnedSolelyBy(
|
|
botId: string,
|
|
attachedBotIds: Array<string | undefined>,
|
|
): boolean {
|
|
return attachedBotIds.every((owner) => owner === botId);
|
|
}
|
|
|
|
export function screenUrlFor(hostPort: string, host = SCREEN_HOST) {
|
|
return `http://${host}:${hostPort}/embed.html`;
|
|
}
|
|
|
|
export function screenUrlWithToken(screenUrl: string, token: string) {
|
|
const url = new URL(screenUrl);
|
|
url.searchParams.set("path", `websockify?token=${encodeURIComponent(token)}`);
|
|
return url.toString();
|
|
}
|
|
|
|
/**
|
|
* Decide which host:port clients (and readiness probes) should use.
|
|
*
|
|
* Per-bot NetworkMode isolation must not change this: a container always has a
|
|
* docker-internal IP on its network, but browsers cannot load that 172.x
|
|
* address. Compose modes that attach the supervisor/screen proxy to the bot
|
|
* network may return the container IP; host-run supervisors use the published
|
|
* loopback mapping.
|
|
*/
|
|
export function resolveScreenPublishTarget(input: {
|
|
screenNetwork: ScreenNetworkMode;
|
|
networkMode: string | null | undefined;
|
|
networks: Record<string, { IPAddress?: string } | undefined> | null | undefined;
|
|
hostPort: string | undefined;
|
|
containerPort: string;
|
|
screenHost?: string;
|
|
}): { host: string; port: string } | undefined {
|
|
if (input.screenNetwork === "internal" || input.screenNetwork === "isolated") {
|
|
const address = input.networkMode ? input.networks?.[input.networkMode]?.IPAddress : undefined;
|
|
if (address) return { host: address, port: input.containerPort };
|
|
return undefined;
|
|
}
|
|
if (input.hostPort) return { host: input.screenHost ?? SCREEN_HOST, port: input.hostPort };
|
|
return undefined;
|
|
}
|
|
|
|
type ControlPortBindings =
|
|
| Record<string, Array<{ HostIp?: string; HostPort?: string }> | null | undefined>
|
|
| null
|
|
| undefined;
|
|
|
|
function validHostPort(port: string | undefined): port is string {
|
|
return !!port && /^\d{1,5}$/.test(port) && Number(port) > 0 && Number(port) <= 65535;
|
|
}
|
|
|
|
/** Resolve an assigned runtime port only when every control binding is loopback. */
|
|
export function publishedLoopbackControlHostPort(portBindings: ControlPortBindings) {
|
|
const bindings = portBindings?.[`${COMPUTER_CONTROL_PORT}/tcp`];
|
|
if (!bindings?.length || bindings.some((binding) => binding.HostIp !== "127.0.0.1")) {
|
|
return undefined;
|
|
}
|
|
return bindings.find((binding) => validHostPort(binding.HostPort))?.HostPort;
|
|
}
|
|
|
|
/**
|
|
* Reuse only containers whose configured control publication matches the setting.
|
|
* Inspect HostConfig so stopped containers and Docker's automatic port allocation
|
|
* (empty or zero HostPort) work before a runtime port has been assigned.
|
|
*/
|
|
export function controlPortPublicationMatches(
|
|
portBindings: ControlPortBindings,
|
|
publishControlPort: boolean,
|
|
): boolean {
|
|
const bindings = portBindings?.[`${COMPUTER_CONTROL_PORT}/tcp`];
|
|
if (!publishControlPort) return !bindings?.length;
|
|
return (
|
|
!!bindings?.length &&
|
|
bindings.every(
|
|
(binding) =>
|
|
binding.HostIp === "127.0.0.1" &&
|
|
(binding.HostPort === "" || binding.HostPort === "0" || validHostPort(binding.HostPort)),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve the computer control service. Prefer a published loopback HostPort
|
|
* when provided; otherwise use the Docker network IP. When requirePublishedHostPort
|
|
* is set, never fall back to the container IP (unreachable from Docker Desktop hosts).
|
|
*/
|
|
export function resolveComputerControlEndpoint(input: {
|
|
token: string | undefined;
|
|
networkMode: string | null | undefined;
|
|
networks: Record<string, { IPAddress?: string } | undefined> | null | undefined;
|
|
publishedHostPort?: string;
|
|
requirePublishedHostPort?: boolean;
|
|
}): { url: string; token: string } | undefined {
|
|
if (!input.token) return undefined;
|
|
if (validHostPort(input.publishedHostPort)) {
|
|
return {
|
|
url: `http://127.0.0.1:${input.publishedHostPort}/v1/desktop`,
|
|
token: input.token,
|
|
};
|
|
}
|
|
if (input.requirePublishedHostPort) return undefined;
|
|
const address =
|
|
(input.networkMode ? input.networks?.[input.networkMode]?.IPAddress : undefined) ||
|
|
Object.values(input.networks ?? {}).find((network) => network?.IPAddress)?.IPAddress;
|
|
if (!address) return undefined;
|
|
return { url: `http://${address}:${COMPUTER_CONTROL_PORT}/v1/desktop`, token: input.token };
|
|
}
|
|
|
|
export function xdotoolCommand(input: SandboxInput): string[] {
|
|
if (input.kind === "key") {
|
|
const key = mapKey(input.key);
|
|
const mods = (input.modifiers ?? []).map(mapKey);
|
|
const combo = [...mods, key].join("+");
|
|
return ["xdotool", "key", "--clearmodifiers", combo];
|
|
}
|
|
if (input.kind === "pointer") {
|
|
const btn = input.button === "right" ? "3" : "1";
|
|
if (input.type === "move")
|
|
return ["xdotool", "mousemove", "--", String(input.x), String(input.y)];
|
|
if (input.type === "down") {
|
|
return ["xdotool", "mousemove", "--", String(input.x), String(input.y), "mousedown", btn];
|
|
}
|
|
if (input.type === "up") return ["xdotool", "mouseup", btn];
|
|
return ["xdotool", "mousemove", "--", String(input.x), String(input.y), "click", btn];
|
|
}
|
|
return ["xdotool", "type", "--clearmodifiers", "--", input.text];
|
|
}
|
|
|
|
function mapKey(key: string) {
|
|
const lower = key.toLowerCase();
|
|
if (lower === "enter" || lower === "return") return "Return";
|
|
if (lower === "esc" || lower === "escape") return "Escape";
|
|
if (lower === "space") return "space";
|
|
if (lower === "tab") return "Tab";
|
|
if (lower === "backspace") return "BackSpace";
|
|
if (lower === "ctrl" || lower === "control") return "ctrl";
|
|
if (lower === "alt") return "alt";
|
|
if (lower === "shift") return "shift";
|
|
if (lower === "meta" || lower === "cmd" || lower === "super") return "super";
|
|
return key;
|
|
}
|