diff --git a/.eleventy.js b/.eleventy.js index a676dc6..78d3f41 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -115,6 +115,9 @@ module.exports = function(eleventyConfig) { "node_modules/d3-geo/dist/d3-geo.min.js": "js/d3-geo.js", "node_modules/topojson-client/dist/topojson-client.min.js": "js/topojson-client.js", "node_modules/world-atlas/land-110m.json": "land-110m.json", + "node_modules/yaml/browser/dist": "js/yaml", + "node_modules/drawflow/dist/drawflow.min.js": "js/drawflow.js", + "node_modules/drawflow/dist/drawflow.min.css": "js/drawflow.css", "src/lib": "/lib", }); diff --git a/.gitignore b/.gitignore index a3ac561..71e6eca 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ package-lock.json !.gitignore !.eleventy.js !.nvmrc +!src/docs/ diff --git a/README.md b/README.md index 0cbdedd..afdb9d1 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,6 @@ npm install npm run dev ``` +## Relay and dump worker + +`worker/` contains the Cloudflare Worker used by setup, the editor, and support dumps. It deploys separately from the Pages site; see [worker/README.md](worker/README.md) for local tests and deployment configuration. diff --git a/package.json b/package.json index f8d1b45..5291746 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "index.js", "scripts": { "clean": "rm -rf _site", + "test": "node --test tests/", "dev": "eleventy --serve & npx tailwindcss -i ./src/styles/main.css -o ./_site/styles.css --watch", "build": "ELEVENTY_PRODUCTION=true eleventy && NODE_ENV=production npx tailwindcss -i ./src/styles/main.css -o ./_site/styles.css --minify" }, @@ -34,6 +35,7 @@ "canvas-sketch-util": "^1.10.0", "d3-array": "^3.2.4", "d3-geo": "^3.1.1", + "drawflow": "0.0.60", "eleventy-plugin-toc": "^1.1.5", "glsl-noise": "^0.0.0", "lucide": "^0.507.0", @@ -42,6 +44,7 @@ "theatre": "^0.2.3", "three": "^0.176.0", "topojson-client": "^3.1.0", - "world-atlas": "^2.0.2" + "world-atlas": "^2.0.2", + "yaml": "2.6.1" } } diff --git a/src/_data/docVersions.js b/src/_data/docVersions.js index 657e133..e79da42 100644 --- a/src/_data/docVersions.js +++ b/src/_data/docVersions.js @@ -1,6 +1,11 @@ module.exports = { - latest: "3.3.6", + latest: "3.3.7", versions: [ + { + id: "3.3.7", + label: "3.3.7", + pathPrefix: "/docs/3.3.7", + }, { id: "3.3.6", label: "3.3.6", diff --git a/src/_includes/layouts/base.njk b/src/_includes/layouts/base.njk index 86bb7ce..6f0a9bf 100644 --- a/src/_includes/layouts/base.njk +++ b/src/_includes/layouts/base.njk @@ -20,6 +20,11 @@ + {% if appPage %} + {# scripts only from this site, connections only to this site and the relay; Alpine needs eval, the layout has inline scripts #} + + + {% endif %} - {% include "partials/analytics.njk" %} + {# the editor and the setup carry a secret in the link: no third-party scripts there #} + {% if not appPage %}{% include "partials/analytics.njk" %}{% endif %} @@ -191,7 +197,7 @@ {% if not hideFooter %} {% include "partials/footer.njk" %} {% endif %} - {% include "partials/cookie-banner.njk" %} + {% if not appPage %}{% include "partials/cookie-banner.njk" %}{% endif %} diff --git a/src/assets/_headers b/src/assets/_headers new file mode 100644 index 0000000..6eecce1 --- /dev/null +++ b/src/assets/_headers @@ -0,0 +1,7 @@ +/editor/* + X-Frame-Options: DENY + Content-Security-Policy: frame-ancestors 'none' + +/setup/* + X-Frame-Options: DENY + Content-Security-Policy: frame-ancestors 'none' diff --git a/src/docs/3.3.7/3.3.7.json b/src/docs/3.3.7/3.3.7.json new file mode 100644 index 0000000..37774c0 --- /dev/null +++ b/src/docs/3.3.7/3.3.7.json @@ -0,0 +1,6 @@ +{ + "layout": "layouts/docs.njk", + "type": "article", + "tags": [ "docs" ], + "docVersion": "3.3.7" +} diff --git a/src/docs/3.3.7/commands.md b/src/docs/3.3.7/commands.md new file mode 100644 index 0000000..b87ec65 --- /dev/null +++ b/src/docs/3.3.7/commands.md @@ -0,0 +1,200 @@ +--- +title: Commands +order: 6 +description: "Reference for all CommandBridge admin commands including reload, info, debug, dump, and the resource-scoped script, task, client, and config trees." +--- + +CommandBridge has admin commands for managing the plugin itself. These are not the custom commands you define in your scripts, those are covered in [Scripting](/docs/scripting/). The commands here are for checking connections, reloading configs, debugging issues, and things like that. + +All admin commands require the `commandbridge.admin` permission. If you don't have a permissions plugin on Velocity, you won't be able to use them in-game. Console always works. + +{% hint "info" %} +v3.3.5 reorganised the `/cb` tree by resource (`script`, `task`, `client`, `config`). The old top-level forms (`/cb scripts`, `/cb tasks`, `/cb list`, `/cb ping`) are gone in v3 with no backwards-compat aliases. Every argument-taking subcommand has live tab-completion: script names, script groups, task ids, client ids, and config keys. +{% endhint %} + +--- + +### Quick command list + +If you just want to know what exists, this is the full list: + +| Where | Command | What it does | +|---|---|---| +| Velocity | `/commandbridge` or `/cb` | Opens the help menu (same as `/cb help`). | +| Velocity | `/cb help` | Shows all admin subcommands. | +| Velocity | `/cb info` | Shows runtime/system info (Java, OS, uptime, memory, CPU). | +| Velocity | `/cb reload` | Reloads config + scripts and re-registers commands. | +| Velocity | `/cb debug` | Toggles debug logging on/off. | +| Velocity | `/cb dump` | Generates a diagnostics dump for support/issue reports. | +| Velocity | `/cb migrate` | Migrates script files to the latest schema version. | +| Velocity | `/cb script list [page]` | Lists loaded scripts (paginated). | +| Velocity | `/cb script show [group]` | Opens a script overview, or drills into a single group. | +| Velocity | `/cb script enable ` | Flips `enabled: true` in the script file and re-pushes registrations. | +| Velocity | `/cb script disable ` | Flips `enabled: false` in the script file and re-pushes registrations. | +| Velocity | `/cb task list [page]` | Lists pending scheduled tasks (paginated). | +| Velocity | `/cb task clear ` | Removes a pending scheduled task by id. | +| Velocity | `/cb client list` | Lists authenticated backend clients. | +| Velocity | `/cb client ping [id]` | Measures latency to one client or all clients. | +| Velocity | `/cb client players ` | Lists players currently on a given backend. | +| Velocity | `/cb config show [key]` | Shows the config overview, or drills into a section. | +| Velocity | `/cb config reload` | Reloads only `config.yml` (no script reload, no re-registration). | +| Backend | `/commandbridgeclient reconnect` or `/cbc reconnect` | Forces reconnect to the proxy. | + +--- + +## What each command does + +### `/commandbridge` or `/cb` + +Running the base command with no subcommand opens the help menu. Internally this behaves like `/cb help`. + +### `/cb help` + +Shows the full admin command list. In-game, each command entry is clickable so you can run it directly from the help output. + +### `/cb info` + +Shows runtime info of the current Velocity instance: OS name and arch, Java version, uptime, memory usage, and CPU core count. + +In-game it also shows a memory load bar so you can quickly see if this proxy is under memory pressure. + +### `/cb reload` + +Reloads `config.yml`, reloads scripts from disk, rebuilds command registration, and pushes registrations to authenticated clients. + +It also gives a result summary per client (`OK`, `FAILED`, `TIMEOUT`) for the registration step, so you can immediately see which backend did not update cleanly. + +{% hint "info" %} +This command reloads config/scripts only. It does not restart the websocket server or Redis connection layer. If endpoint mode/host/port changed, restart the server. +{% endhint %} + +### `/cb debug` + +Toggles debug mode on/off immediately at runtime (`ENABLED` / `DISABLED`). No reload needed. + +{% hint "warning" %} +Debug logging is noisy and can get big fast. Turn it on for troubleshooting, then turn it off again. +{% endhint %} + +### `/cb dump` + +Builds a support snapshot, uploads it to the dump viewer, and saves a local copy. + +- Local copy path: `plugins/commandbridge/dumps/dump-.json` +- Upload retention: currently 14 days +- Sensitive keys like `secret`, passwords, tokens, and pins are redacted + +The dump still contains configuration and script metadata, so review it before sharing publicly. + +### `/cb migrate` + +Migrates script files in `plugins/commandbridge/scripts/` to the latest script schema. + +It prints a per-file result (`MIGRATED`, `SKIPPED`, or `ERROR`) and only touches script files (not `config.yml`). + +Current built-in rules cover `2 -> 3` and `3 -> 4`. + +For a short practical flow, see [Migration](/docs/migration/). + +--- + +### `/cb script list [page]` + +Lists loaded scripts with name, description, and enabled/disabled state. + +- Default page is `1` +- Page size is `5` scripts +- In-game output has clickable previous/next arrows and each entry links to `/cb script show ` + +The summary at the top shows totals for loaded, enabled, disabled, and script errors. + +If a script fails validation, it does not appear here. Check logs for the parser/validation error. + +### `/cb script show [group]` + +Without a group, this opens an overview card for the script: status, schema version, aliases, register targets, and counts for args and commands. Below the summary, it lists clickable section links (`permissions`, `register`, `defaults`, `args`, `commands`). + +With a group, it drills into just that group, rendered the same way the old `/cb scripts` view rendered a full script — as a syntax-highlighted yaml card on console, and as nested key/value lines in chat. + +Tab-completion suggests loaded script names for `` and the five group keys above for `[group]`. + +### `/cb script enable ` / `/cb script disable ` + +Edits the `enabled:` line of the matching yaml file in place. Comments and formatting are preserved. + +After the flip, the registration set is rebuilt and pushed to connected clients, so enabling or disabling a script takes effect immediately — no `/cb reload` needed. + +Tab-completion only suggests scripts that are currently in the opposite state (disabled scripts for `enable`, enabled scripts for `disable`). + +--- + +### `/cb task list [page]` + +Lists pending scheduled tasks created by scripts using `` placeholders. Each entry shows the short task id, the source script, the player it is queued for, and how long ago it was queued. + +- Default page is `1` +- Page size is `5` tasks + +Tasks expire after the duration set in `tasks.expire-after` (default `86400` seconds; set to `0` to disable). Expired tasks are pruned on load and on the 5-minute save tick. + +### `/cb task clear ` + +Removes a pending scheduled task by id. The id is the full UUID — use tab-completion to insert it from the list view. + +--- + +### `/cb client list` + +Lists authenticated and currently open client sessions only. Each entry shows: + +- client ID +- platform location (`VELOCITY` or `BACKEND`) +- remote address +- current player count on that backend + +If a server is missing here, it has not completed auth (or connection is closed). + +### `/cb client ping [id]` + +Measures round-trip latency to clients. + +- No argument: ping all authenticated clients +- With `id`: ping just that client + +Latency quality shown by the command: + +| Latency | Rating | +|---|---| +| `< 50ms` | Excellent | +| `< 150ms` | Good | +| `150ms+` | Poor | + +If a ping request fails or times out, it is shown as `FAILED` in the output. + +### `/cb client players ` + +Lists players currently online on the given backend client. The summary at the bottom shows the total count. Useful for spot-checking that the proxy and a backend agree on who is connected. + +--- + +### `/cb config show [key]` + +Without a key, this shows a flat summary of all top-level scalar config values and a clickable list of nested sections (`server`, `auth`, `commands`, `tasks`, `timeouts`, and so on). + +With a key, it drills into just that section as a yaml card on console or as nested key/value lines in chat. + +Tab-completion suggests top-level config keys. + +### `/cb config reload` + +Reloads only `config.yml`. This does not reload scripts and does not push a registration update to connected clients — use `/cb reload` for that. + +Useful when you only changed a value like `debug` or `tasks.expire-after` and want to apply it without disturbing connected backends. + +--- + +### `/commandbridgeclient reconnect` or `/cbc reconnect` + +Backend-only command. It closes the current backend->proxy session, starts a reconnect attempt, waits briefly, then prints the new connection status. + +Use this when a backend got stuck and you want to refresh the link without restarting the whole server. diff --git a/src/docs/3.3.7/configuration.md b/src/docs/3.3.7/configuration.md new file mode 100644 index 0000000..ac6d71d --- /dev/null +++ b/src/docs/3.3.7/configuration.md @@ -0,0 +1,22 @@ +--- +title: Configuration +order: 4 +description: "Overview of CommandBridge configuration for Velocity and backend servers, including WebSocket, Redis, and multi-proxy networking modes." +--- + +CommandBridge has two different configurations. The one on the Velocity proxy and one on each backend. For each backend, every server that is not Velocity is counted. + +--- + +### CommandBridge offers two ways to build up the network: + +Either **Redis** or **WebSockets**. WebSockets are the standard and the first implementation, and it was originally built on this system. +Redis is the newer method, but an alternative that is not reliable for everyone. The key difference is that in **WebSocket mode**, one Velocity hosts a server while the other backend servers connect to it. +This proxy then manages all traffic. No other setup is required. **Redis mode**, on the other hand, requires a Redis server running externally. CommandBridge will not host a Redis server. +With Redis mode, every CB instance will try to connect to it, including Velocity, but one Velocity instance still manages all message traffic, just the network stack isn’t managed by it anymore. + +I was talking a lot about 'one Velocity instance', because CB supports **multi proxy setups**. What that exactly means is explained here: [Multi proxy](/docs/configuration/multi-proxy/). +But for that to work, there can’t be multiple proxies that manage these messages, so each proxy has in its config the value `act-as-client`. +This enables the client mode for that Velocity instance. So by design it will just act as the other client (as the title says). +For this mode, a `client.yml` will be created with the default client config. Advanced configuration can be viewed in the article. + diff --git a/src/docs/3.3.7/configuration/backends.md b/src/docs/3.3.7/configuration/backends.md new file mode 100644 index 0000000..4ea413a --- /dev/null +++ b/src/docs/3.3.7/configuration/backends.md @@ -0,0 +1,132 @@ +--- +title: Backends +order: 2 +description: "Configuration reference for CommandBridge on backend servers. Set up client ID, connection endpoint, TLS, authentication, and reconnect behavior." +--- + +This config can be found at every backend server at `plugins/commandbridge/config.yml`. + +--- + +## Default config + +```yaml +client-id: survival-1 +endpoint-type: WEBSOCKET +endpoints: + websocket: + host: 127.0.0.1 + port: 8765 + redis: + host: 127.0.0.1 + port: 6379 + username: '' + password: '' +security: + tls-mode: TOFU + tls-pin: '' + secret: change-me +timeouts: + auth-timeout: 5 + reconnect-timeout: 60 + reconnect-interval: 5 +debug: false +``` + +--- + +## Core + +{% configentry "client-id", "string", "" %} +This id is used to identify this client. You are required to change it to a unique name for every backend server. I recommend using a name associated with the server, like `survival-1` if it is a survival server. +{% endconfigentry %} + +{% configentry "endpoint-type", "enum", "WEBSOCKET | REDIS" %} +This works the same as on Velocity. Either connect using Redis or WebSocket; in this enum you can choose what you want. +{% endconfigentry %} + +--- + +## WebSocket + +{% configentry "host", "string", "" %} +The address the WebSocket server is running on. If you are using a local setup you can use `127.0.0.1`, but in most cases you are using a hosting provider or multiple servers, so then you need to enter the public IP address of the Velocity proxy. +{% endconfigentry %} + +{% configentry "port", "integer", "" %} +Use the port you configured in the Velocity proxy CommandBridge config. This must match the `bind-port` value from Velocity. +{% endconfigentry %} + +{% hint "warning" %} +Make sure this port is open as a `TCP` connection on the Velocity server. If it is not reachable, the backend will not be able to connect. +{% endhint %} + +--- + +## Redis + +Redis mode is a bit different because the backend just connects to Redis; it does not host it. The values here should point to your Redis server. + +The backend also needs the same CommandBridge network secret as the proxy. Signed-transport development builds reject unsigned legacy messages, so upgrade all Redis-connected instances together and keep their clocks within two minutes. See [Redis message authentication and upgrade requirements](/docs/configuration/security/#signed-redis-transport). Signing does not encrypt commands. + +{% configentry "host", "string", "" %} +The IP address where the Redis server is running. If you are self-hosting and Redis runs locally, you can usually keep the default value. In most other cases (provider or Docker), you need to change the host. Hosting providers usually give you the IP directly, so you can copy that value. +{% endconfigentry %} + +{% configentry "port", "integer", "" %} +The port Redis is listening on. You do not have to open anything on your router here, just enter the port Redis is running on. +{% endconfigentry %} + +{% configentry "username", "string", "" %} +Redis usually requires authentication for a secure connection, so put your username here. +{% endconfigentry %} + +{% configentry "password", "string", "" %} +The password for your Redis authentication. Used together with `username`. +{% endconfigentry %} + +--- + +## Security + +Authentication and TLS settings. These must match what you configured on the Velocity side. See [Security](/docs/configuration/security/) for a full explanation. + +{% configentry "tls-mode", "enum", "PLAIN | TOFU | STRICT" %} +TLS encryption mode. This must match the proxy's TLS mode. `TOFU` is the default and should be used in most cases. See [Security](/docs/configuration/security/). +{% endconfigentry %} + +{% configentry "tls-pin", "string", "" %} +TLS certificate pin. This is auto-populated in `TOFU` mode on the first connection. You do not need to touch this unless you are re-pinning after a certificate change. +{% endconfigentry %} + +{% configentry "secret", "string", "" %} +Shared secret for authentication. Copy this from Velocity's `secret.key` file. On first startup, Velocity generates this file automatically. +{% endconfigentry %} + +{% hint "danger" %} +Change the default `secret` value before going to production. Use the key from Velocity's `secret.key` file. Anyone with this key can connect to your proxy and execute commands. +{% endhint %} + +--- + +## Timeouts + +{% configentry "auth-timeout", "integer (seconds)", "" %} +Seconds to wait for the proxy to accept authentication. If this times out, the connection attempt is dropped. +{% endconfigentry %} + +{% configentry "reconnect-timeout", "integer (seconds)", "" %} +Max time in seconds to keep trying to reconnect after a disconnect. After this, the backend stops trying. +{% endconfigentry %} + +{% configentry "reconnect-interval", "integer (seconds)", "" %} +Seconds between reconnect attempts. If the connection drops, the backend waits this long before trying again. +{% endconfigentry %} + +--- + +## Debug + +{% configentry "debug", "boolean", "true | false" %} +Enables verbose logging. Useful for troubleshooting connection or script issues. Keep this off in production. +{% endconfigentry %} diff --git a/src/docs/3.3.7/configuration/multi-proxy.md b/src/docs/3.3.7/configuration/multi-proxy.md new file mode 100644 index 0000000..b4cb725 --- /dev/null +++ b/src/docs/3.3.7/configuration/multi-proxy.md @@ -0,0 +1,173 @@ +--- +title: Multi Proxy +order: 4 +description: "Run CommandBridge across multiple Velocity proxies. Configure client-mode proxies that connect to a primary Velocity instance for command forwarding." +--- + +CB supports setups with more than one Velocity proxy. I already talked about this in the [Configuration](/docs/configuration/) overview, but here is the full picture. + +The idea is simple: only one Velocity runs in server mode. Every other Velocity instance runs as a client. That is what the `act-as-client` value in the config is for. +The client-mode proxies connect to the main one and behave like any other backend: they authenticate, receive command registrations, and execute commands. The only difference is that CB internally marks them as `VELOCITY` instead of `BACKEND`, so it knows they are proxies and not actual game servers. + +```mermaid +%%{init: {'theme': 'dark', 'themeVariables': {'fontSize': '18px', 'clusterBkg': 'transparent', 'clusterBorder': 'transparent', 'edgeLabelBackground': 'transparent'}, 'flowchart': {'padding': 20, 'nodeSpacing': 40, 'rankSpacing': 60}} }%% +flowchart TB + subgraph primary ["Primary Velocity"] + V["CommandBridge"] + end + + subgraph clients ["Client-mode proxies"] + V2["Velocity 2
act-as-client: true"] + V3["Velocity 3
act-as-client: true"] + end + + subgraph backends ["Backend servers"] + B1["survival-1"] + B2["creative-1"] + B3["lobby-1"] + end + + V2 -- "connects as client" --> V + V3 -- "connects as client" --> V + B1 -- "connects as client" --> V + B2 -- "connects as client" --> V + B3 -- "connects as client" --> V + + classDef primary fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd + classDef client fill:#3b1f5e,stroke:#a855f7,color:#d8b4fe + classDef backend fill:#14532d,stroke:#22c55e,color:#86efac + class V primary + class V2,V3 client + class B1,B2,B3 backend +``` + +So there is no mesh or anything fancy. One proxy manages everything, the rest just connect to it. + +{% hint "warning" %} +Only **one** Velocity instance can run in server mode. If you start two proxies in server mode on the same Redis instance or the same WebSocket port, things will break. +{% endhint %} + +--- + +## act-as-client + +On every Velocity that should not be the main one, open `plugins/commandbridge/config.yml` and set: + +```yaml +act-as-client: true +``` + +That is the only thing you change in `config.yml`. When this is enabled, CB ignores everything else in that file; endpoints, security, timeouts, all of it. Instead it reads the connection config from a separate file called `client.yml`. + +--- + +## client.yml + +When `act-as-client` is `true`, CB creates a `client.yml` in `plugins/commandbridge/` on first startup. This file looks exactly like a backend config, because that is basically what this proxy becomes: + +```yaml +client-id: proxy-2 +endpoint-type: WEBSOCKET +endpoints: + websocket: + host: 127.0.0.1 + port: 8765 + redis: + host: 127.0.0.1 + port: 6379 + username: '' + password: '' +security: + tls-mode: TOFU + tls-pin: '' + secret: change-me +timeouts: + auth-timeout: 5 + reconnect-timeout: 60 + reconnect-interval: 5 +debug: false +``` + +If you already configured a backend before, this should look familiar. It is the same schema as the [Backends](/docs/configuration/backends/) config. +The `client-id` is the unique name for this proxy, `endpoints` point to wherever the primary proxy is reachable, and `security` needs the same secret as everything else. +I will not explain every field again here since it is identical to the backend config. + +{% hint "info" %} +The `config.yml` is still read on startup to check the `act-as-client` flag. But when it is `true`, everything else in `config.yml` is skipped and `client.yml` takes over completely. +{% endhint %} + +--- + +## Registering & executing across proxies + +In your scripts, you can target more than one instance for both registration and execution. So if you have three Velocity proxies, you can register a command on all of them: + +```yaml +register: + - id: "proxy-1" + location: VELOCITY + - id: "proxy-2" + location: VELOCITY + - id: "proxy-3" + location: VELOCITY +``` + +You can mix proxies and backends too: + +```yaml +register: + - id: "proxy-1" + location: VELOCITY + - id: "survival-1" + location: BACKEND +``` + +The `location` tells CB what type of target it is: `VELOCITY` for a proxy (primary or client-mode), `BACKEND` for a game server. + +The `execute` section works the same way. You can send a command to client-mode proxies, backends, or both: + +--- + +## Player presence tracking + +CB tracks which players are on which server in real time. Every client, both backends and client-mode proxies, reports player changes to the primary. The primary stores all of this in a tracker that maps each client ID to a set of player UUIDs. + +The initial sync on auth is a full snapshot so both sides start from the same state. After that, joins and leaves are sent individually. + +```mermaid +%%{init: {'theme': 'dark', 'themeVariables': {'fontSize': '18px', 'clusterBkg': 'transparent', 'clusterBorder': 'transparent', 'edgeLabelBackground': 'transparent'}, 'flowchart': {'padding': 20, 'nodeSpacing': 40, 'rankSpacing': 60}} }%% +flowchart TB + T{Type?} + T -- "Auth" --> SNAP["Send PLAYER_LIST
(full snapshot)"] + SNAP --> STORE["Store full set"]:::success + T -- "Join" --> PJ["Send PLAYER_JOIN"] + PJ --> ADD["Add UUID to set"]:::success + T -- "Leave" --> DELAY["Wait 1 tick / 50ms"] + DELAY --> PL["Send PLAYER_LEAVE"] + PL --> REM["Remove UUID
from set"]:::success + T -- "Disconnect" --> WIPE["Wipe client's
player set"]:::reject + + classDef success fill:#14532d,stroke:#22c55e,color:#86efac + classDef reject fill:#7f1d1d,stroke:#ef4444,color:#fca5a5 +``` + +> The delay on leave is there so the player is actually gone from the server's player list before the message is sent. + +--- + +## target-required + +The `target-required` setting controls whether a command should only execute on a target if the player is actually on that server. This is where the player tracking from above comes in. + +```yaml +server: + target-required: true +``` + +When this is enabled, CB checks the player tracker before dispatching to each target. If the player is not on that target, the command is skipped for it. + +This works for both **BACKEND** and **VELOCITY** targets. Since every client, whether it is a Paper backend or a client-mode Velocity, sends its player data to the primary, CB has full visibility across the entire network. +It does not matter which proxy the player originally connected through. If a backend reports that `uuid-123` is online, the primary knows about it. + +For the primary Velocity itself there is a small shortcut: CB does not need to check the tracker because the primary already knows its own players directly. But for every remote target the tracker is used, regardless of whether it is a backend or another proxy. +So if you have `target-required: true` and the player is on `survival-1`, the command runs on `survival-1` and gets skipped on `creative-1`. diff --git a/src/docs/3.3.7/configuration/security.md b/src/docs/3.3.7/configuration/security.md new file mode 100644 index 0000000..77086ae --- /dev/null +++ b/src/docs/3.3.7/configuration/security.md @@ -0,0 +1,213 @@ +--- +title: Security +order: 3 +description: "Shared-secret authentication, WebSocket TLS, signed Redis messages, and browser setup precautions." +--- + +CommandBridge uses a **shared secret** for authentication. In WebSocket mode, **TLS** also encrypts the connection. The `tls-mode` setting does not enable TLS for Redis. + +--- + +## Shared secret + +On the first startup, Velocity generates a file called `secret.key` inside `plugins/commandbridge/`. The content is a random 32-byte key encoded in Base64 (43 characters). It looks something like this: + +```key +9RDP43dYvjk8tos86QkU9QxG1V20O27LTR8AN0bPfeg +``` + +This key is used for mutual HMAC-SHA256 authentication. That means both sides prove to each other that they know the secret, without ever sending it over the wire: + +```mermaid +%%{init: {'theme': 'dark', 'themeVariables': {'fontSize': '18px', 'clusterBkg': 'transparent', 'clusterBorder': 'transparent', 'edgeLabelBackground': 'transparent'}, 'flowchart': {'padding': 20, 'nodeSpacing': 30, 'rankSpacing': 60}} }%% +flowchart LR + subgraph b1 [" Backend "] + direction TB + B1["Generate random clientNonce"] --> B2["Compute HMAC(clientId : clientNonce)"] + end + + B2 -- "AUTH_REQUEST
{ clientId, clientNonce, hmac }" --> V1 + + subgraph v [" Velocity "] + direction TB + V1["Recompute HMAC
(clientId : clientNonce)"] --> V2{"HMAC
match?"} + V2 -- "No" --> V3["Close connection"] + V2 -- "Yes" --> V4["Generate random serverNonce"] --> V5["Compute HMAC
(clientId : clientNonce : serverNonce)"] + end + + V3 -- "AUTH_FAIL" --> FAIL["Connection rejected"]:::reject + + V5 -- "AUTH_OK
{ serverNonce, serverHmac }" --> B3 + + subgraph b2 [" Backend "] + direction TB + B3["Recompute HMAC
(clientId : clientNonce : serverNonce)"] --> B4{"Server proof
valid?"} + end + + B4 -- "No" --> FAIL2["Untrusted server"]:::reject + B4 -- "Yes" --> OK["Mutual authentication
complete"]:::success + + classDef reject fill:#5c1a1a,stroke:#ef4444,color:#fca5a5 + classDef success fill:#14532d,stroke:#22c55e,color:#86efac +``` + +The backend signs its client ID and a random nonce with HMAC-SHA256, Velocity verifies it and responds with its own proof using a second nonce, and then the backend verifies that proof. If either side fails, the connection is immediately rejected. + +{% hint "danger" %} +Keep `secret.key` private. Anyone with this key can authenticate to your proxy and execute commands across your network. +{% endhint %} + +### Signed Redis transport + +Development builds sign Redis messages with HMAC-SHA256 derived from the network secret. The signature binds each message to its Redis channel; receivers reject altered, replayed, unsigned or expired messages. Redis credentials alone do not let someone forge CommandBridge messages. + +When upgrading to signed Redis transport, **upgrade and restart every Redis-connected proxy and backend together**. Older builds send unsigned messages, which the new receivers reject. Keep system clocks within two minutes of each other. Sealed messages are limited to 1,048,576 characters; each process tracks at most 65,536 live replay nonces and rejects new messages if that limit is reached. + +All instances still share network-wide trust: anyone holding the network secret can sign commands for the network. HMAC does not encrypt commands. A hostile Redis broker can read, drop or delay messages, so keep Redis access restricted and use a protected network or an encrypted tunnel where needed. + +Replay tracking is memory-only and resets when a process restarts. A captured valid message still within the two-minute clock window can therefore be accepted again after a restart. + +### Browser setup and editor links + +Open setup and editor links from your own proxy console. Someone else can run CommandBridge and create a valid link on the same website; the website address alone does not prove whose proxy it connects to. Keep session links and pairing codes private. Before confirming a pairing on a backend, check the destination shown in its console against your own network settings. + +The bundled relay token is public. It does not prove proxy ownership or prevent resource abuse; this code does not configure platform-level rate limiting. + +Editor changes require confirmation on the proxy. After a reload, review any offered draft and save again to get a new confirmation code. A leaked editor link can leave a draft, so do not confirm changes you have not reviewed. + +Setup and editor confirmations write files individually, not as a transaction. A failure can leave earlier files saved. Check the reported errors and affected files before retrying a multi-file change. + +## Setting up the secret + +On backends, copy the key from Velocity's `secret.key` file into your backend config: + +```yaml +security: + secret: "9RDP43dYvjk8tos86QkU9QxG1V20O27LTR8AN0bPfeg" +``` + +If you leave the `secret` field blank in the backend config, CommandBridge will fall back to loading from the backend's own `secret.key` file. So alternatively you can just copy the `secret.key` file itself to every backend. + +{% hint "warning" %} +The default value for `secret` is `change-me`. If you forget to change it, the plugin will warn you in the console. Change it before going to production. +{% endhint %} + +On Velocity, you do not need to configure anything. The proxy always loads the secret from the `secret.key` file directly. If the file does not exist, it generates a new one. + +{% hint "info" %} +On Linux, `secret.key` is created with `600` permissions (owner read/write only). On Windows this is not possible, so make sure the file is not publicly accessible. +{% endhint %} + +--- + +## TLS + +TLS encrypts all traffic between the proxy and backends so nobody can read or tamper with it. CommandBridge supports three TLS modes. + +> Make sure that the mode is always the same on all servers, otherwise the connection will fail! + +{% hint "warn" %} +TLS only applies to **WebSocket mode**. In Redis mode, CommandBridge does not manage TLS. Use a protected network or an encrypted tunnel for Redis traffic; enabling TLS on Redis alone does not make this client use it. +{% endhint %} + +--- + +### PLAIN + +No encryption. All traffic is sent in plain text over the wire. + +```yaml +security: + tls-mode: PLAIN +``` + +In this mode, the WebSocket URL uses `ws://` instead of `wss://`. + +{% hint "warning" %} +`PLAIN` disables all transport encryption. Anyone who can intercept traffic between your servers can read commands and secrets. Do not use this in production! +{% endhint %} + +--- + +### TOFU (Trust On First Use) + +This is the **default mode** and the one you should use in most cases. It gives you encrypted connections without any manual certificate management. + +```yaml +# Velocity +security: + tls-mode: TOFU + +# Backend +security: + tls-mode: TOFU + tls-pin: "" +``` + +Here is how it works: + +1. On first startup, Velocity generates a self-signed TLS certificate and stores it in `keystore.p12` alongside a randomly generated password in `keystore.pass` +2. The certificate uses RSA 2048-bit with SHA256withRSA and is valid for 10 years +3. When a backend connects for the first time and `tls-pin` is empty, it automatically trusts and pins the server's certificate for that session +4. If you want persistent pinning, copy the SPKI pin from the Velocity startup log into the backend config + +The SPKI pin is logged by Velocity on startup and looks like this: + +``` +TLS SPKI pin: sha256/aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789ABCDEFG= +``` + +To use persistent pinning, put that value in the backend config: + +```yaml +security: + tls-pin: "sha256/aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789ABCDEFG=" +``` + +If you leave `tls-pin` empty, the backend will pin the certificate in memory on every fresh connection. This is still secure for the duration of that session, but it means a man-in-the-middle could theoretically intercept the very first connection. +For most Minecraft servers this is not a realistic threat, but if you want the extra safety, set the pin manually. + +> TOFU gives you encryption with zero manual certificate management. Unless you have a specific reason to change it, keep this as the default. + +--- + +### STRICT + +In this mode you provide your own keystore with your own certificate. CommandBridge does not generate anything. + +```yaml +# Velocity +security: + tls-mode: STRICT + keystore-path: "/path/to/your/keystore.p12" + keystore-password: "your-password" + keystore-type: PKCS12 +``` + +Supported keystore formats are `PKCS12` and `JKS`. If `keystore-type` is not set, it defaults to `PKCS12`. + +On the backend side, you need to set the SPKI pin of the certificate in the keystore: + +```yaml +# Backend +security: + tls-mode: STRICT + tls-pin: "sha256/your-pin-here" +``` + +Use `STRICT` when you have a certificate issued by a CA, when your organization requires specific certificates, or when the TOFU trust model is not acceptable for your setup. + +{% hint "warning" %} +In `STRICT` mode, `keystore-path`, `keystore-password`, and `keystore-type` are all required on Velocity. If any of them are missing, the plugin will not start. +{% endhint %} + +--- + +## Which mode should you use? + +| Scenario | Recommended mode | +|---|---| +| Local development / same machine | `TOFU` | +| Production / servers on different machines | `TOFU` | +| Corporate / compliance requirements | `STRICT` | +| You just don't care about encryption (or testing something) | `PLAIN` | diff --git a/src/docs/3.3.7/configuration/velocity.md b/src/docs/3.3.7/configuration/velocity.md new file mode 100644 index 0000000..79068eb --- /dev/null +++ b/src/docs/3.3.7/configuration/velocity.md @@ -0,0 +1,138 @@ +--- +title: Velocity +order: 1 +description: "Full configuration reference for CommandBridge on the Velocity proxy, including WebSocket, Redis, TLS security, timeouts, and server ID settings." +--- + +This config can be found at `plugins/commandbridge/config.yml`. + +--- + +## Default configuration + +```yaml +act-as-client: false +server-id: proxy-1 +endpoint-type: WEBSOCKET +endpoints: + websocket: + bind-host: 0.0.0.0 + bind-port: 8765 + redis: + host: 127.0.0.1 + port: 6379 + username: '' + password: '' +security: + tls-mode: TOFU + keystore-path: '' + keystore-password: '' + keystore-type: PKCS12 +timeouts: + register-timeout: 5 + ping-timeout: 5 +debug: false +``` + +--- + +## Core + +{% configentry "act-as-client", "boolean", "true | false" %} +When enabled, everything below is ignored and the config from `client.yml` is loaded instead. This is used in multi-proxy setups. See [Multi proxy](/docs/configuration/multi-proxy/) for details. +{% endconfigentry %} + +{% configentry "server-id", "string", "" %} +A unique ID that CB uses to identify this proxy. Keep the default if you are not using a multi-proxy setup. If you are, every proxy needs its own unique name. +{% endconfigentry %} + +{% configentry "endpoint-type", "enum", "WEBSOCKET | REDIS" %} +Determines the backend communication type CB uses. +{% endconfigentry %} + +--- + +## WebSocket + +In WebSocket mode, Velocity hosts the server itself, so these values are named around that behavior. + +{% configentry "bind-host", "string", "" %} +The address the WebSocket server binds to. Usually this should stay at `0.0.0.0` because it covers all network interfaces. Only change this if you know exactly what you are doing. +{% endconfigentry %} + +{% configentry "bind-port", "integer", "" %} +The port CB listens on. Must be free and not used by any other process. When self-hosting, you can normally leave the default. On hosted setups, providers often give you a random port; if so, enter that port here. Remember this port, because it is where CB listens and your backends need to connect to it. +{% endconfigentry %} + +{% hint "warning" %} +You need to open this port as a `TCP` connection in your firewall so it is reachable from the internet. On some setups, this is done in your router. If the port is not open, backends will not be able to connect. +{% endhint %} + +--- + +## Redis + +Redis mode is a bit different because Velocity just connects to Redis; it does not host it. + +Redis credentials and the CommandBridge network secret serve different purposes. For signed-transport development builds, all proxies and backends must upgrade together and keep their clocks within two minutes. See [Redis message authentication and upgrade requirements](/docs/configuration/security/#signed-redis-transport). Signing does not encrypt commands. + +{% configentry "host", "string", "" %} +The IP address where the Redis server is running. If you are self-hosting and Redis runs locally, you can usually keep the default value. In most other cases (provider or Docker), you need to change this. If Redis runs on the same machine, use a local IP address. If not, use the public IP your hosting provider gives you. +{% endconfigentry %} + +{% configentry "port", "integer", "" %} +The port Redis is listening on. You do not need to open anything on your router since you are not hosting a server here. +{% endconfigentry %} + +{% configentry "username", "string", "" %} +Redis usually requires authentication for a secure connection. Enter your Redis username here. +{% endconfigentry %} + +{% configentry "password", "string", "" %} +The password for your Redis authentication. Used together with `username`. +{% endconfigentry %} + +--- + +## Security + +Authentication and TLS settings. See [Security](/docs/configuration/security/) for details. + +{% configentry "tls-mode", "enum", "PLAIN | TOFU | STRICT" %} +TLS encryption mode. See [Security](/docs/configuration/security/). `TOFU` is the default and should be used in most cases. +{% endconfigentry %} + +{% hint "info" %} +`TOFU` gives you encryption with zero manual certificate management. Only change this if you know what you are doing. +{% endhint %} + +{% configentry "keystore-path", "string", "" %} +Path to a PKCS12 or JKS keystore. Only used with `STRICT` mode. This loads the keystore from a custom path so you can use your own certificates. +{% endconfigentry %} + +{% configentry "keystore-password", "string", "" %} +The password for your custom keystore. Only relevant when using `STRICT` mode with a custom `keystore-path`. +{% endconfigentry %} + +{% configentry "keystore-type", "enum", "PKCS12 | JKS" %} +Keystore format. Only affects `STRICT` mode. +{% endconfigentry %} + +--- + +## Timeouts + +{% configentry "register-timeout", "integer (seconds)", "" %} +Seconds to wait for a backend to confirm command registration. If this times out, the connection is closed. +{% endconfigentry %} + +{% configentry "ping-timeout", "integer (seconds)", "" %} +Seconds to wait for a ping response before timing out and printing an error. +{% endconfigentry %} + +--- +## Debug + +{% configentry "debug", "boolean", "true | false" %} +Enables verbose logging. Useful for troubleshooting connection or script issues. Keep this off in production. +{% endconfigentry %} diff --git a/src/docs/3.3.7/developer-api.md b/src/docs/3.3.7/developer-api.md new file mode 100644 index 0000000..c73ddf8 --- /dev/null +++ b/src/docs/3.3.7/developer-api.md @@ -0,0 +1,9 @@ +--- +title: Developer API +order: 10 +description: "Public Java API for interacting with the CommandBridge network from third-party plugins." +--- + +CommandBridge has a public Java API for other plugins to interact with the bridge network. You can send messages to remote servers, listen for incoming messages, subscribe to server events, and look up player locations from the proxy. + +The API is published to Maven Central as `dev.objz:commandbridge-api`. The source is in the `api/` module on [GitHub](https://github.com/objz/CommandBridge). diff --git a/src/docs/3.3.7/developer-api/channels.md b/src/docs/3.3.7/developer-api/channels.md new file mode 100644 index 0000000..a8b06d5 --- /dev/null +++ b/src/docs/3.3.7/developer-api/channels.md @@ -0,0 +1,112 @@ +--- +title: Channels +order: 2 +description: "MessageChannel is the typed communication pipe for the CommandBridge network. Both sides of the connection need the same payload class." +--- + +Channels are how you send and receive messages across the CommandBridge network. Each channel is typed and routes messages based on the payload class you use to open it. + +The easiest way to explain it is with an example: + +```java +import dev.objz.commandbridge.api.CommandBridgeAPI; +import dev.objz.commandbridge.api.CommandBridgeProvider; +import dev.objz.commandbridge.api.channel.MessageChannel; +import dev.objz.commandbridge.api.channel.command.CommandPayload; +import dev.objz.commandbridge.api.channel.command.RunAs; +import static dev.objz.commandbridge.api.platform.Platform.backend; + +CommandBridgeAPI api = CommandBridgeProvider.get(); +MessageChannel channel = api.channel(CommandPayload.class); + +channel.to(List.of(backend("survival-1"))) + .send(new CommandPayload("say hello", RunAs.CONSOLE)); +``` + +First, we call `api.channel(CommandPayload.class)` to get a typed channel. The class token is the routing key, so both the sending and receiving side must call `api.channel()` with the same class. + +Then, we build a sender with `channel.to()`. We pass a list with one target, `backend("survival-1")`, which refers to the backend server configured with `client-id: survival-1`. + +Finally, we call `send()` on the sender with a `CommandPayload`. The message is dispatched asynchronously. The returned `CompletableFuture` resolves when the message has been dispatched over the network. + +--- + +## Channel routing + +Channels are identified by the payload type's fully-qualified class name, determined by `Class.getName()`. A `MessageChannel` on Velocity and one on a backend refer to the same channel because `CommandPayload` resolves to `dev.objz.commandbridge.api.channel.command.CommandPayload` on both sides. + +For custom payload types, both the sending and receiving server must have the class on the classpath with the same fully-qualified name. If a message arrives for a type that is not registered on the receiving side, it is silently dropped. + +--- + +## Targeting + +`Platform.backend(id)` and `Platform.velocity(id)` construct `ServerTarget` instances that identify which server to send to: + +```java +import static dev.objz.commandbridge.api.platform.Platform.backend; +import static dev.objz.commandbridge.api.platform.Platform.velocity; + +Platform.ServerTarget s1 = backend("survival-1"); +Platform.ServerTarget px = velocity("proxy-1"); +``` + +The `id` passed to `backend()` must match the `client-id` in that server's `config.yml`. The `id` passed to `velocity()` must match the `server-id` in the proxy's `config.yml`. + +`velocity()` targets are only useful in multi-proxy setups where one Velocity instance connects as a client to another. In most setups you only need `backend()`. + +If the target server is not connected at send time, `send()` returns a failed `CompletableFuture`. + +--- + +## Sender types + +The return type of `channel.to()` and `channel.toAll()` depends on how you call them. There are three sender types. + +A **single-target sender** is returned when you pass exactly one target to `to()`: + +```java +channel.to(List.of(backend("survival-1"))) +``` + +It supports `send()`, `request()`, and delivery conditions (`requirePlayer`, `whenOnline`). + +A **multi-target sender** is returned when you pass two or more targets to `to()`: + +```java +channel.to(List.of(backend("survival-1"), backend("creative-1"))) +``` + +It supports `send()` and delivery conditions. Calling `request()` on a multi-target sender throws `UnsupportedOperationException`. + +A **broadcast sender** is returned by `toAll()`: + +```java +channel.toAll() +``` + +It broadcasts to all currently connected and authenticated servers. The target set is resolved at dispatch time. Only `send()` is supported. Calling `request()` or any delivery condition throws `UnsupportedOperationException`. + +--- + +## MessageChannel methods + +```java +MessageChannel

api.channel(Class

type) +``` + +Gets the channel for the given payload type. Channels are cached per type, so repeated calls with the same class return the same instance. + +```java +Sender

to(Collection targets) +``` + +Creates a sender targeting the given servers. Must have at least one entry. Returns a `SingleTargetSender` for one target and a `MultiTargetSender` for two or more. + +```java +Sender

toAll() +``` + +Creates a broadcast sender targeting all connected and authenticated servers. The target set is resolved at the time `send()` is called, not when `toAll()` is called. + +For registering listeners and managing subscriptions, see [Receiving](/docs/developer-api/receiving/). diff --git a/src/docs/3.3.7/developer-api/delivery-conditions.md b/src/docs/3.3.7/developer-api/delivery-conditions.md new file mode 100644 index 0000000..3a5b54c --- /dev/null +++ b/src/docs/3.3.7/developer-api/delivery-conditions.md @@ -0,0 +1,114 @@ +--- +title: Delivery conditions +order: 5 +description: "Gate message delivery on player presence with requirePlayer() and whenOnline()." +--- + +Delivery conditions let you gate a message on whether a specific player is present on the target server. Chain them on the sender before calling `send()`. + +The easiest way to explain it is with an example: + +```java +import dev.objz.commandbridge.api.channel.command.CommandPayload; +import dev.objz.commandbridge.api.channel.command.RunAs; +import static dev.objz.commandbridge.api.platform.Platform.backend; + +channel.to(List.of(backend("survival-1"))) + .requirePlayer(playerUUID) + .send(new CommandPayload("home", RunAs.PLAYER, playerUUID)); +``` + +First, we build a sender targeting `survival-1`. + +Then, we call `requirePlayer(playerUUID)`. This adds a condition: only deliver the message if `playerUUID` is currently on `survival-1`. If the player is not there at dispatch time, the message is silently dropped. + +Finally, we call `send()` with the payload. + +--- + +## requirePlayer + +`requirePlayer(UUID)` checks whether the specified player is on the target server at dispatch time. + +If the player is not on the server, the behavior depends on how you send: + +With `send()`, the message is dropped silently. The future completes normally with `null`. + +With `request()`, the future completes exceptionally with `IllegalStateException("Player not on target server: {id}")`. + +--- + +## whenOnline + +`whenOnline(UUID)` queues the message if the player is not currently on the target server. The message is held and delivered automatically when that player next connects to the target. + +```java +channel.to(List.of(backend("survival-1"))) + .whenOnline(playerUUID) + .send(new CommandPayload("give " + playerUUID + " diamond 1", RunAs.CONSOLE)); +``` + +The queue lives in memory on the Velocity proxy and is not persisted to disk. If the proxy restarts, queued messages are lost. + +If you need delivery to survive a proxy restart, use the scripting system's [`schedule-online`](/docs/scripting/syntax/) instead. That saves tasks to disk and has no TTL. The API queue is intentionally lightweight and in-memory. There is no persistent equivalent for custom payload types since the scripting system only supports command execution. + +Each queued message has a 5-minute TTL, but this is not a background timer. The TTL is checked when the player joins the target server. If they join within 5 minutes of the message being queued, it is delivered. If they join after that, the message is discarded at that point. + +The total number of queued messages across all players and all target servers is capped at 1000. If the cap is reached, new messages are dropped with a warning logged. + +If the target server disconnects before the player joins, all messages queued for that server are removed. + +{% hint "warning" %} +`whenOnline` is not supported with `request()`. It throws `UnsupportedOperationException`. +{% endhint %} + +--- + +## No-arg variants + +Both conditions have no-arg overloads that extract the player UUID from the payload automatically: + +```java +channel.to(List.of(backend("survival-1"))) + .requirePlayer() + .send(new CommandPayload("home", RunAs.PLAYER, playerUUID)); + +channel.to(List.of(backend("survival-1"))) + .whenOnline() + .send(new CommandPayload("teleport ...", RunAs.CONSOLE, playerUUID)); +``` + +These only work with `CommandPayload`. The implementation calls `.player()` on the payload. If the payload is not a `CommandPayload`, or its `player` component is `null`, an `IllegalStateException` is thrown at dispatch time. + +--- + +## Constraints + +| Situation | Result | +|---|---| +| `requirePlayer` + player absent + `send()` | message dropped, future completes with `null` | +| `requirePlayer` + player absent + `request()` | `IllegalStateException` | +| `whenOnline` + player absent + `send()` | queued (in-memory, lazy 5-min TTL, 1000 global cap) | +| `whenOnline` + `request()` | `UnsupportedOperationException` | +| `requirePlayer` + `whenOnline` combined | `IllegalStateException` | +| `requirePlayer` or `whenOnline` on broadcast sender | `UnsupportedOperationException` | +| No-arg variant with non-`CommandPayload` | `IllegalStateException` | +| No-arg variant with `CommandPayload` where `player` is `null` | `IllegalStateException` | + +--- + +## Delivery condition methods + +```java +Sender

requirePlayer(UUID player) +Sender

requirePlayer() +``` + +Gates delivery on the player being present on the target server at dispatch time. The no-arg overload uses `CommandPayload.player()`. + +```java +Sender

whenOnline(UUID player) +Sender

whenOnline() +``` + +Queues the message until the player connects to the target server. The no-arg overload uses `CommandPayload.player()`. Not supported with `request()` or on broadcast senders. diff --git a/src/docs/3.3.7/developer-api/events.md b/src/docs/3.3.7/developer-api/events.md new file mode 100644 index 0000000..ffcbe18 --- /dev/null +++ b/src/docs/3.3.7/developer-api/events.md @@ -0,0 +1,214 @@ +--- +title: Events +order: 7 +description: "Subscribe to server connect and disconnect events, track connection state, and look up player locations from the proxy." +--- + +The API exposes events for server lifecycle, connection state changes, and player tracking. Some of this is proxy-only. Each section notes which platforms support it. + +--- + +## Server lifecycle events + +**Proxy-only.** Both methods return `Optional.empty()` on backends. + +`onServerConnected` fires when a backend completes authentication. `onServerDisconnected` fires when a session is removed, either from a clean disconnect or a lost connection. + +```java +import dev.objz.commandbridge.api.message.Subscription; +import dev.objz.commandbridge.api.platform.Platform; + +private final List subs = new ArrayList<>(); + +@Subscribe +public void onProxyInitialize(ProxyInitializeEvent event) { + api.onServerConnected(server -> { + String id = server.id(); + Platform type = server.type(); + }).ifPresent(subs::add); + + api.onServerDisconnected(server -> { + String id = server.id(); + }).ifPresent(subs::add); +} + +@Subscribe +public void onProxyShutdown(ProxyShutdownEvent event) { + subs.forEach(Subscription::cancel); + subs.clear(); +} +``` + +The `ServerTarget` passed to each listener has `.id()` (the server's configured identifier) and `.type()` (`Platform.BACKEND` or `Platform.VELOCITY`). + +`.ifPresent(subs::add)` works on both platforms without any platform checks. On Velocity you get a `Subscription`, on backends you get an empty `Optional` and nothing happens. These methods are proxy-only, so in practice they only register on Velocity. `List` is used here because two listeners are registered together and need to be canceled as a pair. + +--- + +## Connection state + +`onConnectionStateChanged` is available on all platforms. The listener is called on every state transition. + +{% tabs %} +{% tab "Velocity" %} +```java +import dev.objz.commandbridge.api.message.Subscription; +import dev.objz.commandbridge.api.platform.ConnectionState; + +private Subscription connectionListener; + +@Subscribe +public void onProxyInitialize(ProxyInitializeEvent event) { + connectionListener = api.onConnectionStateChanged(state -> { + boolean active = state.isActive(); + ConnectionState current = state; + }); +} + +@Subscribe +public void onProxyShutdown(ProxyShutdownEvent event) { + connectionListener.cancel(); +} +``` +{% endtab %} +{% tab "Paper / Bukkit" %} +```java +import dev.objz.commandbridge.api.message.Subscription; +import dev.objz.commandbridge.api.platform.ConnectionState; + +private Subscription connectionListener; + +@Override +public void onEnable() { + connectionListener = api.onConnectionStateChanged(state -> { + boolean active = state.isActive(); + ConnectionState current = state; + }); +} + +@Override +public void onDisable() { + connectionListener.cancel(); +} +``` +{% endtab %} +{% endtabs %} + +You can also query the current state directly at any time: + +```java +ConnectionState state = api.connectionState(); +boolean ready = state.isActive(); +``` + +`isActive()` returns `true` only when the state is `AUTHENTICATED`. + +**ConnectionState values:** + +| State | Meaning | +|---|---| +| `DISCONNECTED` | No connection. Initial state. | +| `CONNECTING` | Socket is being established. | +| `CONNECTED` | Socket is open, authentication not yet complete. Messages cannot be sent. | +| `AUTHENTICATED` | Fully connected. `isActive()` returns `true`. | +| `RECONNECTING` | Connection dropped unexpectedly. Reconnect in progress. | +| `AUTH_FAILED` | Authentication failed. No further attempts will be made. | + +**Platform differences:** + +On Velocity, `connectionState()` always returns `AUTHENTICATED`. `onConnectionStateChanged` fires immediately with `AUTHENTICATED` and returns a no-op `Subscription`. Canceling it does nothing. + +On backends, the state reflects the actual client connection to the proxy. When you call `onConnectionStateChanged`, a background daemon thread starts polling every 500ms. Listeners are called on transitions. + +{% hint "info" %} +If you see `AUTH_FAILED` on a backend, check that `secret` in the backend's `config.yml` matches the `secret.key` file on Velocity. +{% endhint %} + +--- + +## Player locator + +**Proxy-only.** Returns `Optional.empty()` on backends. + +`playerLocator()` returns a service for finding which server a player is currently on. + +```java +api.playerLocator().ifPresent(locator -> { + Optional location = locator.locate(playerUUID); + // location.isPresent() — player is tracked + // location.get().id() — server identifier +}); +``` + +`locate(UUID)` returns empty if the player is offline or not tracked by any authenticated session. + +--- + +## Connected servers + +**Proxy-only.** Returns `Optional.empty()` on backends. + +`connectedServers()` returns a snapshot of all server IDs that are currently connected and authenticated: + +```java +api.connectedServers().ifPresent(servers -> { + // servers — Set of authenticated server IDs +}); +``` + +This is a point-in-time snapshot. Use `onServerConnected` and `onServerDisconnected` to track changes in real time instead. + +--- + +## Server identity + +Available on all platforms. `api.server()` returns a `ServerTarget` with the current server's own configured ID and platform type: + +```java +Platform.ServerTarget me = api.server(); +String id = me.id(); +Platform type = me.type(); +``` + +On Velocity, `server()` returns `Platform.VELOCITY.target(serverId)` where `serverId` is the `server-id` from `config.yml`. On backends, it returns `Platform.BACKEND.target(clientId)` where `clientId` is the `client-id` from `config.yml`. + +--- + +## Event methods + +```java +Optional onServerConnected(ServerEventListener listener) +Optional onServerDisconnected(ServerEventListener listener) +``` + +Register a listener for server connect and disconnect events. Proxy-only. Returns `Optional.empty()` on backends. + +```java +Subscription onConnectionStateChanged(Consumer listener) +``` + +Register a listener for connection state transitions. Available on all platforms. + +```java +ConnectionState connectionState() +``` + +Returns the current connection state. Always `AUTHENTICATED` on Velocity. Reflects the actual connection state on backends. + +```java +Optional playerLocator() +``` + +Returns a player location service. Proxy-only. Returns `Optional.empty()` on backends. + +```java +Optional> connectedServers() +``` + +Returns a snapshot of connected and authenticated server IDs. Proxy-only. Returns `Optional.empty()` on backends. + +```java +Platform.ServerTarget server() +``` + +Returns the current server's own identity. Available on all platforms. diff --git a/src/docs/3.3.7/developer-api/payloads.md b/src/docs/3.3.7/developer-api/payloads.md new file mode 100644 index 0000000..7b29758 --- /dev/null +++ b/src/docs/3.3.7/developer-api/payloads.md @@ -0,0 +1,113 @@ +--- +title: Payloads +order: 6 +description: "Define custom payload types for message channels. CommandPayload is the built-in type for command execution." +--- + +Payloads are the data you send over a channel. `CommandPayload` is the built-in type for dispatching commands on remote servers. For anything beyond command execution you define your own payload types. + +--- + +## CommandPayload + +`CommandPayload` is a record with three components: + +```java +public record CommandPayload(String command, RunAs runAs, UUID player) implements ChannelPayload +``` + +| Component | Type | Description | +|---|---|---| +| `command` | `String` | The command to run, without a leading `/`. | +| `runAs` | `RunAs` | How the command runs on the target server. | +| `player` | `UUID` | The player to run it as. `null` for `CONSOLE`. | + +`RunAs` controls execution context: + +| Value | Runs as | Player required | +|---|---|---| +| `CONSOLE` | Server console with full permissions | No | +| `PLAYER` | The specified player with their own permissions | Yes | +| `OPERATOR` | The specified player with temporary op-level permissions | Yes | + +`OPERATOR` grants op status for that one command only and revokes it immediately after. Use `CONSOLE` when no player context is needed. + +{% hint "info" %} +`OPERATOR` no longer grants wildcard `*` permission. It grants only the explicit permissions the backend platform's op system provides. +{% endhint %} + +There is a two-arg constructor for the console case: + +```java +new CommandPayload("say hello", RunAs.CONSOLE) +new CommandPayload("home", RunAs.PLAYER, playerUUID) +new CommandPayload("gamemode creative", RunAs.OPERATOR, playerUUID) +``` + +--- + +## Custom payload types + +`ChannelPayload` is a marker interface. Any class that implements it can be sent over a channel. Java records are the recommended approach because Jackson serializes them automatically without any configuration. + +```java +import dev.objz.commandbridge.api.channel.ChannelPayload; + +public record PlayerDataPayload(UUID playerId, String displayName, int level) implements ChannelPayload { +} + +public record ServerStatusPayload(String serverId, int playerCount, boolean accepting) implements ChannelPayload { +} +``` + +Use custom payloads exactly like `CommandPayload`: + +```java +import dev.objz.commandbridge.api.channel.MessageChannel; +import dev.objz.commandbridge.api.message.Subscription; +import static dev.objz.commandbridge.api.platform.Platform.backend; + +MessageChannel channel = api.channel(ServerStatusPayload.class); + +channel.to(List.of(backend("lobby-1"))) + .send(new ServerStatusPayload("survival-1", 47, true)); + +Subscription sub = channel.listen((ctx, payload) -> { + String serverId = payload.serverId(); + int count = payload.playerCount(); + boolean accepting = payload.accepting(); +}); +``` + +### Channel routing + +Channels are keyed by the payload type's fully-qualified class name (`Class.getName()`). Both the sending and receiving side must use the same class with the same fully-qualified name. If a message arrives for a type not registered on the receiving side, it is silently dropped. + +This means shared payload types need to be on the classpath of every server that participates in that channel. The standard approach is a shared module or jar that all involved plugins depend on. + +--- + +## Serialization + +Payloads are serialized to JSON using Jackson, configured with `Jdk8Module` (for `Optional`), `JavaTimeModule` (for `java.time.*`), and `findAndRegisterModules()` to auto-discover additional modules. + +Supported field types: primitives, `String`, `UUID`, `Optional`, `java.time.*` types (`Instant`, `Duration`, `LocalDate`, `LocalDateTime`, etc.), and collections (`List`, `Set`, `Map`). + +```java +import dev.objz.commandbridge.api.channel.ChannelPayload; + +public record EventPayload( + UUID playerId, + String event, + Instant occurredAt, + Optional metadata +) implements ChannelPayload {} +``` + +Records do not need `@JsonProperty` annotations. Jackson maps record components by name automatically. + +If Jackson cannot serialize a field, the message is dropped and a warning is logged on the sending side. If Jackson cannot deserialize incoming data into the expected type, the message is dropped with a warning on the receiving side. No exception is thrown to your listener in either case. + +{% hint "warning" %} +Custom payload classes must exist on the classpath of both the sending and receiving server with the same fully-qualified class name. If the names differ, messages are silently dropped. +{% endhint %} diff --git a/src/docs/3.3.7/developer-api/receiving.md b/src/docs/3.3.7/developer-api/receiving.md new file mode 100644 index 0000000..912c039 --- /dev/null +++ b/src/docs/3.3.7/developer-api/receiving.md @@ -0,0 +1,121 @@ +--- +title: Receiving +order: 4 +description: "Subscribe to incoming messages on a channel with listen(). Inspect the source and timestamp via MessageContext." +--- + +To receive messages on a channel, register a `MessageListener` with `channel.listen()`. The listener is called for every incoming message on that channel. + +The easiest way to explain it is with an example: + +```java +import dev.objz.commandbridge.api.channel.MessageChannel; +import dev.objz.commandbridge.api.channel.command.CommandPayload; +import dev.objz.commandbridge.api.message.Subscription; + +MessageChannel channel = api.channel(CommandPayload.class); + +Subscription sub = channel.listen((ctx, payload) -> { + String from = ctx.from().id(); + String command = payload.command(); +}); +``` + +First, we call `channel.listen()` with a lambda that takes a `MessageContext` and the payload. The context carries metadata about the message: which server sent it and when. + +Then, we store the returned `Subscription`. We need it to unregister the listener during plugin shutdown. + +The listener is invoked on the thread that processes the incoming network message. If you need to dispatch to a specific thread (Bukkit main thread, Folia region, Velocity event thread), schedule that explicitly inside the listener. + +Multiple listeners can be registered on the same channel and all of them are called for each incoming message. + +--- + +## MessageContext + +`MessageContext` is a record with three components: + +| Component | Type | Description | +|---|---|---| +| `channel()` | `Class` | The payload class token, identifying which channel the message arrived on | +| `from()` | `Platform.ServerTarget` | The server that sent the message | +| `timestamp()` | `long` | Unix epoch milliseconds when the message was sent | + +`ctx.from()` returns a `ServerTarget` with `.id()` (the server's configured identifier) and `.type()` (`Platform.BACKEND` or `Platform.VELOCITY`). + +{% hint "info" %} +On backends, `ctx.from().type()` is always `BACKEND`. On Velocity, the type is inferred from the session and can be `BACKEND` for connected backends or `VELOCITY` for other proxy instances in a multi-proxy setup. +{% endhint %} + +--- + +## Canceling a subscription + +`Subscription.cancel()` unregisters the listener. It is idempotent, so calling it more than once is safe. + +Use a single `Subscription` field when you register one listener. Use `List` when you register multiple listeners that need to be canceled together (as shown on the [Events](/docs/developer-api/events/) page for `onServerConnected` and `onServerDisconnected`). + +{% tabs %} +{% tab "Velocity" %} +```java +import dev.objz.commandbridge.api.message.Subscription; + +private Subscription commandListener; + +@Subscribe +public void onProxyInitialize(ProxyInitializeEvent event) { + commandListener = channel.listen((ctx, payload) -> { + String from = ctx.from().id(); + String command = payload.command(); + }); +} + +@Subscribe +public void onProxyShutdown(ProxyShutdownEvent event) { + commandListener.cancel(); +} +``` +{% endtab %} +{% tab "Paper / Bukkit" %} +```java +import dev.objz.commandbridge.api.message.Subscription; + +private Subscription commandListener; + +@Override +public void onEnable() { + commandListener = channel.listen((ctx, payload) -> { + String from = ctx.from().id(); + String command = payload.command(); + }); +} + +@Override +public void onDisable() { + commandListener.cancel(); +} +``` +{% endtab %} +{% endtabs %} + +If you don't cancel subscriptions during shutdown, the listener reference stays in the channel's internal list and prevents garbage collection. + +{% hint "warning" %} +Always cancel subscriptions during shutdown. Store every `Subscription` returned by `listen()` and cancel them all on disable. +{% endhint %} + +--- + +## Receiving methods + +```java +Subscription listen(MessageListener

listener) +``` + +Registers a listener for incoming messages on this channel. Returns a `Subscription`. Multiple listeners can be registered on the same channel and all are called for each incoming message. + +```java +void cancel() +``` + +Unregisters the listener. Called on the `Subscription` returned by `listen()`. Idempotent. diff --git a/src/docs/3.3.7/developer-api/sending.md b/src/docs/3.3.7/developer-api/sending.md new file mode 100644 index 0000000..908f50d --- /dev/null +++ b/src/docs/3.3.7/developer-api/sending.md @@ -0,0 +1,96 @@ +--- +title: Sending +order: 3 +description: "Send messages to remote servers with send() for fire-and-forget dispatch, or request() for request-response." +--- + +Sending messages is done through a `Sender` you get from `channel.to()` or `channel.toAll()`. There are two ways to send: fire-and-forget with `send()`, or request-response with `request()`. + +The easiest way to explain it is with an example: + +```java +import dev.objz.commandbridge.api.channel.MessageChannel; +import dev.objz.commandbridge.api.channel.command.CommandPayload; +import dev.objz.commandbridge.api.channel.command.RunAs; +import static dev.objz.commandbridge.api.platform.Platform.backend; + +MessageChannel channel = api.channel(CommandPayload.class); + +channel.to(List.of(backend("survival-1"))) + .send(new CommandPayload("say hello", RunAs.CONSOLE)); +``` + +First, we get a sender by calling `channel.to()` with a list containing one target, `backend("survival-1")`. + +Then, we call `send()` with a `CommandPayload`. This dispatches the message and returns a `CompletableFuture` that resolves when the message has been sent over the network. The remote server processes the command independently. + +That's it for a basic send. The sections below cover `send()` in detail, the `request()` method, and how messages are relayed. + +--- + +## send() + +`send()` dispatches the payload to all targets and returns a `CompletableFuture`. The future resolves when the message has been dispatched, not when the remote has processed it. + +```java +channel.to(List.of(backend("survival-1"), backend("creative-1"))) + .send(new CommandPayload("say hello", RunAs.CONSOLE)); + +channel.toAll() + .send(new CommandPayload("broadcast restart in 5 minutes", RunAs.CONSOLE)); +``` + +If a target is not connected at dispatch time, `send()` returns a failed future with `IllegalStateException`. + +--- + +## request() + +`request()` sends to a single server and waits for the remote to send back a response payload. It returns a `CompletableFuture

` that resolves with the response. + +```java +channel.to(List.of(backend("survival-1"))) + .request(new CommandPayload("ping", RunAs.CONSOLE)) + .thenAccept(response -> { + String command = response.command(); + }); +``` + +The default timeout is 15 seconds. Pass a `Duration` to use a different one: + +```java +channel.to(List.of(backend("survival-1"))) + .request(new CommandPayload("ping", RunAs.CONSOLE), Duration.ofSeconds(3)) + .thenAccept(response -> response.command()); +``` + +If the remote does not respond within the timeout, the future completes exceptionally. + +`request()` is only supported on single-target senders. Calling it on a multi-target sender or a broadcast sender throws `UnsupportedOperationException`. `whenOnline` is also not compatible with `request()`, see [Delivery conditions](/docs/developer-api/delivery-conditions/) for details. + +--- + +## Relay + +Velocity acts as the hub for all channel messages. When a backend sends a message to another backend, it does not communicate directly. The message goes to Velocity first, which relays it to the target. The `MessageContext.from()` in the receiving listener reflects the original sender, not the proxy. + +{% hint "info" %} +If the proxy is not reachable or the target backend is not connected to it, messages cannot be delivered. +{% endhint %} + +--- + +## Sender methods + +```java +CompletableFuture send(P payload) +``` + +Dispatches the payload to all targets. Returns a `CompletableFuture` that resolves when the message has been sent. Returns a failed future if a target is not connected. + +```java +CompletableFuture

request(P payload) +CompletableFuture

request(P payload, Duration timeout) +``` + +Sends to a single target and waits for a response payload. Only supported on single-target senders. Default timeout is 15 seconds. diff --git a/src/docs/3.3.7/developer-api/setup.md b/src/docs/3.3.7/developer-api/setup.md new file mode 100644 index 0000000..9c6194f --- /dev/null +++ b/src/docs/3.3.7/developer-api/setup.md @@ -0,0 +1,119 @@ +--- +title: Setup +order: 1 +description: "Add the commandbridge-api dependency and get the API instance in your plugin." +--- + +To use the CommandBridge API in your plugin, add the `commandbridge-api` dependency to your build and declare CommandBridge as a plugin dependency so it loads first. + +--- + +## Dependency + +Add the dependency to your build script. + +{% tabs %} +{% tab "Maven" %} +```xml + + dev.objz + commandbridge-api + 3.3.7 + provided + +``` +{% endtab %} +{% tab "Gradle (Groovy)" %} +```groovy +compileOnly 'dev.objz:commandbridge-api:3.3.7' +``` +{% endtab %} +{% tab "Gradle (Kotlin)" %} +```kotlin +compileOnly("dev.objz:commandbridge-api:3.3.7") +``` +{% endtab %} +{% endtabs %} + +Shading the API into your jar is not supported. CommandBridge bundles the API in its own jar and provides it at runtime, so use `provided` or `compileOnly`. + +--- + +## Plugin dependency + +Declare CommandBridge as a required dependency in your plugin descriptor so it loads before your plugin. + +{% tabs %} +{% tab "Velocity" %} +```java +@Plugin( + id = "myplugin", + dependencies = { @Dependency(id = "commandbridge") } +) +public class MyPlugin { } +``` +{% endtab %} +{% tab "Paper (paper-plugin.yml)" %} +```yaml +dependencies: + server: + CommandBridge: + load: BEFORE + required: true + join-classpath: true +``` + +`join-classpath: true` is required. Paper uses isolated classloaders, so your plugin can't see CommandBridge's API classes without it. +{% endtab %} +{% tab "Bukkit / Spigot (plugin.yml)" %} +```yaml +depend: + - CommandBridge +``` +{% endtab %} +{% endtabs %} + +--- + +## Getting the instance + +Call `CommandBridgeProvider.get()` to get the API instance. Store it as a field and call it from the right lifecycle method. + +{% tabs %} +{% tab "Velocity" %} +```java +import dev.objz.commandbridge.api.CommandBridgeAPI; +import dev.objz.commandbridge.api.CommandBridgeProvider; + +private CommandBridgeAPI api; + +@Subscribe +public void onProxyInitialize(ProxyInitializeEvent event) { + api = CommandBridgeProvider.get(); +} +``` +{% endtab %} +{% tab "Paper / Bukkit" %} +```java +import dev.objz.commandbridge.api.CommandBridgeAPI; +import dev.objz.commandbridge.api.CommandBridgeProvider; + +private CommandBridgeAPI api; + +@Override +public void onEnable() { + api = CommandBridgeProvider.get(); +} +``` +{% endtab %} +{% endtabs %} + +{% hint "warning" %} +`CommandBridgeProvider.get()` throws `IllegalStateException` if CommandBridge is not installed or has not finished enabling. +{% endhint %} + +If you need to cast to a platform-specific subtype, use the typed overload. It throws `IllegalStateException` if the registered implementation is not an instance of the requested type: + +```java +CommandBridgeProvider.get(MyPlatformExtension.class) +``` diff --git a/src/docs/3.3.7/getting-started.md b/src/docs/3.3.7/getting-started.md new file mode 100644 index 0000000..bf283a4 --- /dev/null +++ b/src/docs/3.3.7/getting-started.md @@ -0,0 +1,119 @@ +--- +title: Getting Started +order: 3 +description: "Step-by-step guide to installing CommandBridge, configuring WebSocket or Redis connections, and writing your first cross-server command script." +--- + +### Step 1: Install the plugin + +I assume that everyone knows how to install plugins. +Install CommandBridge on every server you want to connect to the CB network. + +{% hint "info" %} +CommandBridge is bundled in a single jar, so you do not have to install different versions. One jar works everywhere. +{% endhint %} + + +### Step 2: Configure the connection on Velocity + +After you verified that CommandBridge started up correctly and the config was created, open it at `plugins/commandbridge/config.yml` on Velocity: + +CommandBridge has two modes to run the network, either `REDIS` mode or the default `WEBSOCKET` mode. + +```yaml +endpoint-type: REDIS +endpoints: + websocket: + bind-host: 0.0.0.0 + bind-port: 8765 + redis: + host: 127.0.0.1 + port: 6379 + username: '' + password: '' +``` + +This section needs to be changed to configure the connection. As said, change `endpoint-type` to what you want to use. +While one mode is selected, the values from the other mode are ignored. First I focus on the websocket section, as it is the default: + +The `bind-host` is set to `0.0.0.0` by default, which you can leave in most cases. If you know what you are doing, you probably know what to enter. +The `bind-port` can be any port, but it has to be an unused one. You need to open this port in the firewall/router to the internet with TCP. + +The redis section is nearly the same. The only difference here is that CommandBridge does not host a Redis instance, so you only need to enter your Redis credentials there. +Enter the address or IP of the Redis server and the port. In this case you do not have to change anything in the firewall like before, because you only connect. +The username and password should be self-explanatory (most of the time they are actually not). + +Each CommandBridge instance has an ID that you have to specify. For Velocity, you can modify (or not, as the default value is usually enough) the `server-id` value. +It is set to `proxy-1` by default. If you use more proxies in your setup, you need to change the names. Otherwise it is pretty unnecessary. + +--- + +### Step 3: Configure each backend + +Open `plugins/commandbridge/config.yml` on each backend: + +The important section should look like this (nearly identical to the Velocity one): + +```yaml +endpoints: + websocket: + host: 127.0.0.1 + port: 8765 + redis: + host: 127.0.0.1 + port: 6379 + username: '' + password: '' +``` + +The only difference here is the naming and the function. I will not explain the Redis section again, as it is identical to before. +The websocket section has the values `host` and `port`, with slightly different names than before. This is because backends do not host the websocket server, they just connect to it. +So for backends you do not have to reserve or open any ports like you had before. +For `host`, enter the public IP address of the Velocity server with the running websocket server. You can also use the local IP address if you use a local setup. +And the port just has to match the port on Velocity. Not wizardry here. + +Also here you need to change the ID of the instance, and this time it is important to change it. +Every backend acts as a client and needs its own specific ID. +The value is called `client-id` and by default it is set to `survival-1`. +I would recommend changing it to the server name or something obvious you can recognize. +Cryptic names are not that smart in this case. + +{% hint "info" %} +`client-id` should be unique for every backend. +{% endhint %} + + +### Step 4: Set up authentication + +On first startup, Velocity generates a `secret.key` file (it will be regenerated if you delete it). + +The content of the file will contain a key and look something like this: + +```key +9RDP43dYvjk8tos86QkU9QxG1V20O27LTR8AN0bPfeg +``` + +Copy this key into each config of your backend instances. You need to put it in the `secret` value in the `security` section. +An example section will look like this: + +```yaml +security: + secret: "9RDP43dYvjk8tos86QkU9QxG1V20O27LTR8AN0bPfeg" +``` + + +{% hint "danger" %} +Keep `secret.key` private. Anyone with this key can issue commands across your network. +{% endhint %} + + +### Step 5: Start your network + +Pretty self-explanatory. Start your Velocity server and the backend servers. +If you run in Redis mode, make sure Redis is running. +If you run in websocket mode, ensure that Velocity starts first, because the websocket server needs to run first. +After that you can start up any other servers. + + +> CommandBridge comes with an example script. You may need to enable it, or you can start by writing your own. +For that, visit the [Scripting](/docs/scripting/) section. diff --git a/src/docs/3.3.7/index.md b/src/docs/3.3.7/index.md new file mode 100644 index 0000000..11671b9 --- /dev/null +++ b/src/docs/3.3.7/index.md @@ -0,0 +1,24 @@ +--- +title: Welcome +order: 1 +description: "Documentation for CommandBridge 3.3.7, a Velocity plugin for executing commands across your entire Minecraft network using YAML scripts." +--- + +This is the documentation for CommandBridge. + +CommandBridge is a cross-server command execution plugin for Minecraft networks running via Velocity. You define commands in YAML scripts on the proxy, and CB registers and dispatches them across all connected backend servers. No plugin messaging, no player required on the target server, no limitations. + +--- + +### What's new in 3.3.7 + +The `feat/web-editor` development branch adds browser setup and a script editor with console confirmation, plus signed Redis messages. See the [security and upgrade notes](/docs/3.3.7/configuration/security/) before using these changes. + +--- + +### Links + +- [Migration Guide](/docs/migration/) +- [GitHub](https://github.com/objz/CommandBridge) +- [Modrinth](https://modrinth.com/plugin/commandbridge) +- [Discord](https://discord.gg/QPqBYb44ce) diff --git a/src/docs/3.3.7/migration.md b/src/docs/3.3.7/migration.md new file mode 100644 index 0000000..a4d02b1 --- /dev/null +++ b/src/docs/3.3.7/migration.md @@ -0,0 +1,46 @@ +--- +title: Migration +order: 9 +description: "Short migration guide for updating CommandBridge scripts to the latest schema with /cb migrate." +--- + +This page is for migrating **older scripts** to the current script schema. + +Short version: maybe? make a backup, run `/cb migrate`, then test your commands. + +--- + +## What `/cb migrate` does + +- It scans `plugins/commandbridge/scripts/` +- It migrates script files to the latest schema (`version: 4`) +- It prints a result per file: `MIGRATED`, `SKIPPED`, or `ERROR` +- It does **not** migrate `config.yml` + +Current built-in migration steps: + +- `2 -> 3` +- `3 -> 4` + +--- + +## Recommended flow + +1. Stop and create a backup of your scripts folder. +2. Start Velocity and run `/cb migrate`. +3. Run `/cb reload`. +4. Test the important commands manually. + +If a script shows `ERROR`, fix that script and run migrate again. + +--- + +{% hint "warning" %} +Migration is best-effort. For bigger custom scripts, you may still need manual cleanup. +{% endhint %} + +If you need to fix things manually, use: + +- [Scripting](/docs/scripting/) +- [Syntax](/docs/scripting/syntax/) +- [Troubleshooting](/docs/troubleshooting/) diff --git a/src/docs/3.3.7/permissions.md b/src/docs/3.3.7/permissions.md new file mode 100644 index 0000000..b94f265 --- /dev/null +++ b/src/docs/3.3.7/permissions.md @@ -0,0 +1,34 @@ +--- +title: Permissions +order: 7 +description: "CommandBridge permission nodes for admin commands and per-script command access. All permissions are managed on the Velocity proxy." +--- + +CommandBridge permission handling is simple. + +You need a permissions plugin on Velocity, because command usage permissions are checked there. +So in practice: use LuckPerms on Velocity (`lpv`). + +--- + +### Permission nodes + +| Permission | What it does | +|---|---| +| `commandbridge.admin` | Access to admin commands (`/cb` on Velocity, `/cbc reconnect` on backends). | +| `commandbridge.command.` | Permission to use a script command (`` = script `name`). | + +Example: if your script name is `alert`, the permission is `commandbridge.command.alert`. + +--- + +### Important + +Every permission for script command usage must be set on Velocity. + +Even if the command executes on a backend, permission is checked on the proxy side first. + +```bash +# grant command usage on Velocity +lpv user playerName permission set commandbridge.command.alert true +``` diff --git a/src/docs/3.3.7/requirements.md b/src/docs/3.3.7/requirements.md new file mode 100644 index 0000000..aa51af6 --- /dev/null +++ b/src/docs/3.3.7/requirements.md @@ -0,0 +1,45 @@ +--- +title: Requirements +order: 2 +description: "Supported platforms, Java version, Minecraft versions, and required dependencies for running CommandBridge on Velocity, Paper, Folia, Spigot, and Purpur." +--- + +CommandBridge has some requirements that need to be installed alongside it. Also, the supported platforms are listed here. + +--- + +### Runtime + +| Requirement | Version | +|---|---| +| Java | 21+ | +| Minecraft | 1.20.x to 1.21.x | + + +### Platforms + +| Platform | Supported | +|---|---| +| Velocity | Yes | +| Paper | Yes | +| Folia | Yes | +| Bukkit / Spigot | Yes | +| Purpur | Yes | +| Forge | No | +| Fabric | No | +| NeoForge | No | + + + +### Requirements + +| Plugin/Library | Where | Why | Required | +|---|---|---|---| +| [CommandAPI](https://modrinth.com/plugin/commandapi) | Velocity/Backends | For command dispatching | required | +| [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) | Backends | For PAPI placeholders | optional | +| [PapiProxyBridge](https://modrinth.com/plugin/papiproxybridge) | Velocity | Resolve PAPI placeholders proxy-side | optional | +| [PacketEvents](https://modrinth.com/plugin/packetevents) | Velocity | Enable the `TIME` argument type | optional | + +{% hint "info" %} +CommandBridge requires a permissions plugin on velocity. [LuckPerms](https://luckperms.net/) is recommended. +{% endhint %} diff --git a/src/docs/3.3.7/scripting.md b/src/docs/3.3.7/scripting.md new file mode 100644 index 0000000..1e7fd40 --- /dev/null +++ b/src/docs/3.3.7/scripting.md @@ -0,0 +1,88 @@ +--- +title: Scripting +order: 5 +description: "Learn how CommandBridge scripts work. Define commands in YAML on the Velocity proxy and dispatch them across all connected backend servers." +--- + +Scripts are the core of CommandBridge. They are YAML files that define custom commands: what they are called, what arguments they take, who can use them, and what happens when someone runs them. You write them once, drop them on the Velocity proxy, and CB handles the rest. + +All scripts live in one place: + +``` +plugins/commandbridge/scripts/ +``` + +This folder only exists on the **Velocity proxy**. Backends do not have their own scripts folder. Velocity is the brain of the operation. It reads all scripts, validates them, registers commands on whichever targets you specified, and dispatches execution when someone actually uses them. You manage everything from one location. + +{% hint "info" %} +Scripts are loaded on startup. Use `/cb reload` to reload them without restarting. If a script has errors, CB tells you exactly what is wrong and skips it. The other scripts still load fine. +{% endhint %} + +--- + +### How it works + +You create a `.yml` file in the scripts folder. In that file you define the command name, its arguments, where it should be registered, where it should execute, and the actual commands to run. That sounds like a lot, but most of the time you define your defaults once and then just list the commands. + +On startup (or when you run `/cb reload`), CommandBridge reads all script files, validates them against a strict schema, and registers the commands on the targets you specified. If the target is a backend, CB pushes the registration over the network. If it is Velocity, it registers locally. + +When a player or console actually runs the command, CB processes it through a pipeline. It resolves arguments, checks permissions, applies cooldowns, replaces placeholders, and then dispatches the final command to each target. If anything fails at any stage, the pipeline stops and nothing half-executed reaches the servers. + +The pipeline is what makes CB reliable. You don't get partial execution. Either everything passes and the command runs, or it doesn't. + +--- + +### Script format version + +Every script starts with `version: 4`. This tells CB which schema to use for parsing. Older versions are not loaded directly; use `/cb migrate` first. + +--- + +### Default script + +CommandBridge ships with an example script that gets copied to the scripts folder on first startup. It is disabled by default, but it shows the basic structure of a script: + +```yaml +version: 4 + +name: example +description: An example command script - edit or replace this with your own +enabled: false +aliases: [ex] + +permissions: + enabled: true + silent: false + +register: + - id: "client-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: player + required: true + type: STRING + suggestions: [] + + - name: message + required: true + type: TEXT + suggestions: [] + +commands: + - command: "msg ${player} ${message}" +``` + +This registers `/example` on the Velocity proxy. When someone runs `/example Steve hello`, the command `msg Steve hello` is forwarded to the `client-1` backend and executed as console. To try it out, set `enabled` to `true` and change the `id` values to match your actual setup. diff --git a/src/docs/3.3.7/scripting/argument-types.md b/src/docs/3.3.7/scripting/argument-types.md new file mode 100644 index 0000000..d9b648e --- /dev/null +++ b/src/docs/3.3.7/scripting/argument-types.md @@ -0,0 +1,163 @@ +--- +title: Argument Types +order: 2 +description: "All supported argument types for CommandBridge scripts. Includes universal types like STRING and INTEGER, plus Minecraft-specific types for players, entities, and more." +--- + +CommandBridge supports many argument types. Each type controls how the input is parsed, what tab completion the player sees, and where the argument can be used. Not all types work everywhere, so pay attention to the platform column. + +If you don't set a `type` for an argument, it defaults to `STRING`. + +--- + +## Universal types + +These work on both Velocity and backends. If you are not sure what to use, start here. They cover most use cases. + +| Type | Description | Example input | +|------|-------------|---------------| +| `STRING` | A single word. No spaces allowed. This is the default. | `hello` | +| `OFFLINE_PLAYER` | A player name (online or cached offline). Useful when the target might not be online right now. | `Steve` | +| `INTEGER` | A whole number. Negative numbers work too. | `42`, `-5` | +| `BOOLEAN` | Either `true` or `false`. | `true` | +| `DOUBLE` | A decimal number. | `3.14` | +| `TEXT` | A single token, can be quoted to include spaces. Must be the last argument. | `"hello world"` | +| `GREEDY_STRING` | Everything after this point, including spaces. | `this is a long message` | + +`STRING` is the safe default. `GREEDY_STRING` is what you want for messages or reasons where the input can be multiple words. You can also use `TEXT`, but you have to enclose your input into "...". + +{% hint "info" %} +`TEXT` and `GREEDY_STRING` must always be the last argument in your `args` list. They consume the remaining input, so you can't put anything after them. +{% endhint %} + +--- + +## Minecraft types + +These use CommandAPI's argument system under the hood and give you rich tab completion with Minecraft-aware suggestions. +Most of them only work on **backend servers** because they rely on Bukkit/Paper APIs that are not available on Velocity. Some also have specific implementations that only work with **PacketEvents**. + + +### Entity + +| Type | Description | Example input | +|------|-------------|---------------| +| `ENTITIES` | One or more entities. Supports selectors with filters. | `@e[type=zombie]` | +| `ENTITY_TYPE` | An entity type from the registry. | `zombie`, `creeper` | + +If your command targets entities, this is where you want to look. + +### World and location + +| Type | Description | Example input | +|------|-------------|---------------| +| `WORLD` | A loaded world name. | `world`, `world_nether` | +| `LOCATION` | A 3D position (x y z). Supports relative coordinates with `~`. | `100 64 -200`, `~ ~1 ~` | +| `LOCATION_2D` | A 2D position (x z). Same as `LOCATION` but without the Y coordinate. | `100 -200` | +| `ANGLE` | A rotation angle. Supports relative values. | `90`, `~45` | +| `ROTATION` | A full rotation (yaw pitch). | `90 0` | + +### Item and effect + +| Type | Description | Example input | +|------|-------------|---------------| +| `ITEM_STACK` | An item. | `diamond_sword`, `stone` | +| `ENCHANTMENT` | An enchantment type. | `sharpness`, `protection` | +| `POTION_EFFECT` | A potion effect type. | `speed`, `regeneration` | +| `SOUND` | A sound identifier. | `entity.experience_orb.pickup` | +| `BIOME` | A biome identifier. | `plains`, `desert` | + +### Numeric + +| Type | Description | Example input | +|------|-------------|---------------| +| `RANGE` | A numeric range. Uses CommandAPI's IntegerRange argument. Useful for commands that accept a range of values. | `1..10`, `5..` | + +--- + +## Velocity-only types + +| Type | Description | Example input | +|------|-------------|---------------| +| `SERVER` | A server name from the Velocity proxy config. Perfect for commands like `/send` where the player picks a server. | `lobby`, `survival` | + +--- + +## Conditional types + +| Type | Platforms | Condition | +|------|-----------|-----------| +| `PLAYERS` | Backend (always), Velocity (conditional) | Works on backends by default. On Velocity, requires [PacketEvents](https://modrinth.com/plugin/packetevents) to be installed. Without PacketEvents on Velocity, the type is not available there. | +| `TIME` | Backend (always), Velocity (conditional) | Works on backends by default. On Velocity, requires [PacketEvents](https://modrinth.com/plugin/packetevents) to be installed. Without PacketEvents on Velocity, the type is not available there. | + +--- + +## Platform availability + +This is important. If you use a backend-only type in a script that registers on Velocity, validation will fail and the script won't load. CB checks this at load time so you find out immediately instead of getting a confusing error at runtime. + +| Type | Velocity | Backend | +|------|----------|---------| +| `STRING` | yes | yes | +| `INTEGER` | yes | yes | +| `BOOLEAN` | yes | yes | +| `DOUBLE` | yes | yes | +| `TEXT` | yes | yes | +| `GREEDY_STRING` | yes | yes | +| `OFFLINE_PLAYER` | yes | yes | +| `SERVER` | yes | no | +| `TIME` | with PacketEvents | yes | +| `PLAYERS` | with PacketEvents | yes | +| `RANGE` | no | yes | +| `ENTITIES` | no | yes | +| `ENTITY_TYPE` | no | yes | +| `WORLD` | no | yes | +| `LOCATION` | no | yes | +| `LOCATION_2D` | no | yes | +| `ANGLE` | no | yes | +| `ROTATION` | no | yes | +| `ITEM_STACK` | no | yes | +| `ENCHANTMENT` | no | yes | +| `POTION_EFFECT` | no | yes | +| `SOUND` | no | yes | +| `BIOME` | no | yes | + + +--- + +## Serialization + +When a command is dispatched, argument values are converted to strings for the `${name}` placeholder. How that works depends on the type: + +| Type | Serialized as | +|------|--------------| +| `PLAYERS`, `ENTITIES` | Space-separated player/entity names | +| `OFFLINE_PLAYER` | The provided player name | +| `LOCATION` | `x y z` | +| `LOCATION_2D` | `x z` | +| Any collection | Space-separated values | +| Missing optional argument | Empty string | +| Everything else | Standard `.toString()` | + +In most cases you don't have to think about this. It just works. The only edge case is when you use `PLAYERS` with a selector like `@a` that matches multiple players. In that case, the placeholder resolves to all matching names separated by spaces. Your command needs to handle that. + +--- + +## Custom suggestions + +You can provide static tab completion suggestions for any argument using the `suggestions` field: + +```yaml +args: + - name: action + required: true + type: STRING + suggestions: + - start + - stop + - restart +``` + +Each suggestion value must match lowercase alphanumeric with `.`, `_`, `+`, `-`. + +Suggestions are only hints for tab completion. The player can still type anything the argument type accepts. So even with suggestions, `STRING` will accept any word, not just the ones you listed. diff --git a/src/docs/3.3.7/scripting/examples.md b/src/docs/3.3.7/scripting/examples.md new file mode 100644 index 0000000..c5ee57a --- /dev/null +++ b/src/docs/3.3.7/scripting/examples.md @@ -0,0 +1,289 @@ +--- +title: Examples +order: 4 +description: "Real-world CommandBridge script examples. Alert broadcasts, economy commands, lobby systems, and more to help you get started quickly." +--- + +Real-world script examples to get you started. Each one demonstrates a different use case. You can copy them, change the `id` values to match your setup, and they should work. + +All scripts go in `plugins/commandbridge/scripts/` on Velocity. + +--- + +## Alert: broadcast to multiple servers + +Broadcast a maintenance warning to all your backend servers from the proxy. This is probably the most common use case for CB. + +```yaml +version: 4 +name: alert +description: Broadcast an alert to all servers +enabled: true +aliases: [] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "lobby" + location: BACKEND + - id: "survival" + location: BACKEND + - id: "minigames" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: message + required: true + type: GREEDY_STRING + suggestions: [] + +commands: + - command: "say [Alert]: ${message}" +``` + +**What happens:** +- Registers `/alert` on the Velocity proxy +- When someone runs it, `say [Alert]: ` is sent to the console on all three backends +- Uses `GREEDY_STRING` so the message can contain spaces without quoting + +**Permission:** `commandbridge.command.alert` + +--- + +## Lobby: send player to another server + +Send the player to the lobby server. This is registered on a backend so players can run `/lobby` while playing on survival (or any other game mode). + +```yaml +version: 4 +name: lobby +description: Send the player to the lobby +enabled: true +aliases: [hub] + +permissions: + enabled: true + silent: false + +register: + - id: "survival-1" + location: BACKEND + +defaults: + run-as: PLAYER + execute: + - id: "proxy-1" + location: VELOCITY + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: [] + +commands: + - command: "server lobby" +``` + +**What happens:** +- Registers `/lobby` (and `/hub`) on the backend `survival-1` +- When a player runs it, `server lobby` is forwarded to Velocity and executed as the player +- The player gets transferred to the `lobby` server + +**Key detail:** `run-as: PLAYER` is required here because `server lobby` needs to run in the player's context on Velocity. If you used `CONSOLE`, it would try to send the console to lobby, which obviously doesn't work. + +**Permission:** `commandbridge.command.lobby` + +{% hint "info" %} +This technique can be used to bridge Velocity-only commands to your backend servers. In this case, the `/server lobby` command would exist as a native backend command, allowing plugins on that server to execute it and, for example, send players to the lobby. +{% endhint %} + +--- + +## Economy: typed arguments with backend-only types + +Give money to a player. Demonstrates using argument types like `PLAYERS` and `INTEGER` for proper tab completion and input validation. + +```yaml +version: 4 +name: eco-give +description: Give money to a player on a backend +enabled: true +aliases: [eco] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: player + required: true + type: PLAYERS + suggestions: [] + + - name: amount + required: true + type: INTEGER + suggestions: [] + +commands: + - command: "eco give ${player} ${amount}" +``` + +**What happens:** +- Registers `/eco-give` (and `/eco`) on `proxy-1` +- `PLAYERS` gives tab completion for online players +- `INTEGER` only accepts whole numbers, so a player can't type random text here + +**Permission:** `commandbridge.command.eco-give` + +--- + +## Punishment: multiple commands and per-command overrides + +Ban a player across the network. This shows how to run multiple commands in one script and override execution targets per command. A common pattern for network-wide moderation. + +```yaml +version: 4 +name: netban +description: Ban a player on all servers +enabled: true +aliases: [nban] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + - id: "lobby-1" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: target + required: true + type: PLAYERS + suggestions: [] + + - name: reason + required: false + type: GREEDY_STRING + suggestions: [] + +commands: + - command: "ban ${target} ${reason}" + + - command: "say ${target} has been banned: ${reason}" + execute: + - id: "admin-1" + location: BACKEND + run-as: CONSOLE +``` + +**What happens:** + +1. `/netban Steve cheating` is run on Velocity +2. First command: `ban Steve cheating` runs on `survival-1` and `lobby` consoles (uses the defaults) +3. Second command: `say Steve has been banned: cheating` runs on the Admin backend console (overrides `execute` to target only one backend instead of all backends) + +**Permission:** `commandbridge.command.netban` + +--- + +## Delayed reward: using delay and schedule-online + +Give a target player a reward 30 seconds after the command is run. If that target player is offline, the command is queued and executes when they come back. + +```yaml +version: 4 +name: claim-reward +description: Claim your daily reward +enabled: true +aliases: [reward] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: true + player-arg: "player" + delay: 0s + cooldown: 24h + +args: + - name: player + required: true + type: OFFLINE_PLAYER + suggestions: [] + +commands: + - command: "say ${player} is claiming their daily reward..." + - command: "give ${player} diamond 5" + delay: 30s +``` + +**What happens:** + +1. Someone runs `/claim-reward ` on Velocity +2. The first command broadcasts immediately on the backend +3. The second command waits 30 seconds, then gives the player 5 diamonds +4. If the target player is offline, the command is queued and runs when they reconnect +5. The `cooldown: 24h` prevents them from using it again for 24 hours (cooldowns are not persisted across restarts) + +**Permission:** `commandbridge.command.claim-reward` diff --git a/src/docs/3.3.7/scripting/placeholders.md b/src/docs/3.3.7/scripting/placeholders.md new file mode 100644 index 0000000..b1f9832 --- /dev/null +++ b/src/docs/3.3.7/scripting/placeholders.md @@ -0,0 +1,104 @@ +--- +title: Placeholders +order: 3 +description: "Use argument placeholders and PlaceholderAPI in CommandBridge scripts. Resolve dynamic values like player names, amounts, and external data in commands." +--- + +CommandBridge uses `${name}` placeholders in command strings to insert argument values at runtime. It also supports PlaceholderAPI if you need external data like player ranks or balances. + +There are two placeholder systems and they use different syntax, so they don't conflict with each other. + +--- + +## Argument placeholders + +Every argument defined in your `args` list becomes a `${name}` placeholder you can reference in `commands`. The name in the placeholder must match the argument name exactly: + +```yaml +name: eco-give +args: + - name: player + required: true + type: PLAYERS + suggestions: [] + + - name: amount + required: true + type: INTEGER + suggestions: [] + +commands: + - command: "eco give ${player} ${amount}" +``` + +When someone runs `/eco-give Steve 100`, the command resolves to `eco give Steve 100` and gets dispatched to the target. + +--- + +## Optional arguments + +If an optional argument is not provided by the player, its placeholder resolves to an empty string: + +```yaml +name: msg-test +args: + - name: target + required: true + type: PLAYERS + suggestions: [] + + - name: message + required: false + type: GREEDY_STRING + suggestions: [] + +commands: + - command: "msg ${target} ${message}" +``` + +Running `/msg-test Steve` resolves to `msg Steve ` (with a trailing space). Running `/cmd Steve hello` resolves to `msg Steve hello`. Keep in mind that the trailing space might matter for some commands, so be aware of it when using optional arguments at the end. + +--- + +## Validation + +Placeholders are validated when the script loads. CB checks that every `${name}` in your command strings actually has a matching argument in the `args` list. If you write `${amount}` but there is no argument named `amount`, you get a warning. + +- Placeholder names must match the argument `name` exactly. They are case-sensitive. +- Only arguments referenced as `${name}` in at least one command string are actually used during execution. +- Use descriptive names: `${player}`, `${amount}`, `${reason}`. Not `${a}`, `${b}`, `${c}`. Your future self will thank you. + +--- + +## PlaceholderAPI integration + +CommandBridge integrates with [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) (PAPI) to resolve external placeholders in your command strings. This lets you inject data like player ranks, balances, or any other PAPI expansion into your commands. + +### Requirements + +| Plugin | Where | Purpose | +|--------|-------|---------| +| [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) | Backends | Provides placeholder resolution on backend servers | +| [PapiProxyBridge](https://modrinth.com/plugin/papiproxybridge) | Velocity | Bridges PAPI resolution to the proxy side | + +Both are optional. Without them, `%placeholder%` tokens in your commands stay as-is and are not resolved. The commands still work, you just don't get PAPI values. +> CommandBridge automaticly detecs whether PlaceholderAPI is installed - you don't have to enable anything. + +### How it works + +After CommandBridge resolves all `${arg}` placeholders, it passes the resulting command string through PlaceholderAPI if available. Any `%placeholder%` tokens are then replaced with their PAPI values. + +You can mix both systems in the same command: + +```yaml +commands: + - command: "say Welcome %luckperms_prefix% ${player}!" +``` + +If the triggering player has the `[Admin]` prefix and the `player` argument is `Steve`: + +> `say Welcome [Admin] Steve!` + +{% hint "warning" %} +PAPI placeholders are resolved in the context of the **triggering player**, not the target. If you need target-specific data (like the target's balance), the target must be the executor using `run-as: PLAYER`. +{% endhint %} diff --git a/src/docs/3.3.7/scripting/syntax.md b/src/docs/3.3.7/scripting/syntax.md new file mode 100644 index 0000000..3bd5221 --- /dev/null +++ b/src/docs/3.3.7/scripting/syntax.md @@ -0,0 +1,256 @@ +--- +title: Syntax +order: 1 +description: "Complete YAML script syntax reference for CommandBridge. Covers command name, arguments, permissions, execution targets, cooldowns, delays, and more." +--- + +### Default example + +This shows every field you can use in a script. Most of the time you won't need all of them, but this is the full picture so you know what exists: + +```yaml +version: 4 + +name: example +description: An example command script - edit or replace this with your own +enabled: false +aliases: [ex] + +permissions: + enabled: true + silent: false + +register: + - id: "client-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: player + required: true + type: STRING + suggestions: [] + + - name: message + required: true + type: TEXT + suggestions: [] + +commands: + - command: "msg ${player} ${message}" + server: + target-required: true + delay: 2s +``` + +Don't worry if this looks like a lot. It's not that complex. I just wanted to show everything in one place. + +--- + +## Top-level fields + +These are the fields at the root of the script file. They define what the command is called, whether it is active, and some basic metadata. + +{% configentry "version", "integer", "4 | 3 | 2" %} +Schema version. Use `4`. Older versions are not loaded directly; migrate them first. +{% endconfigentry %} + +{% configentry "name", "string", "" %} +The command name. This becomes `/name` in-game. Must be lowercase, start with a letter, 3 to 33 characters long, and only contain `a-z`, `0-9`, or `-`. So `example-command` is fine, `ExampleCommand` or `e` is not. +{% endconfigentry %} + +{% configentry "description", "string", "" %} +A short description shown in `/help `. Not required, but I recommend setting one so people know what the command does without reading the script file. +{% endconfigentry %} + +{% configentry "enabled", "boolean", "true | false" %} +Set to `false` to disable the script without deleting the file. Disabled scripts are loaded but not registered anywhere. +{% endconfigentry %} + +{% configentry "aliases", "list", "" %} +Alternative command names. Same naming rules as `name`. So if your command is called `example`, you could add `ex` as an alias. +{% endconfigentry %} + +--- + +## permissions + +Controls whether CommandBridge checks the `commandbridge.command.` permission before executing. See [Permissions](/docs/permissions/) for details on where to set them. + +{% configentry "enabled", "boolean", "true | false" %} +When `true`, the player needs `commandbridge.command.` to run this command. When `false`, everyone can use it. +{% endconfigentry %} + +{% configentry "silent", "boolean", "true | false" %} +When `true`, permission denials are silent. The player gets no error message, the command just does nothing. Useful for commands that should be invisible to unauthorized players. +{% endconfigentry %} + +--- + +## register + +A list of targets where the command should be registered. Each entry needs an `id` and a `location`. This is where the command shows up in tab completion and where players can type it. + +{% configentry "id", "string", "" %} +The `server-id` or `client-id` of the target. Must match exactly what is in that instance's config. If it doesn't match, dispatch to that target fails at runtime. +{% endconfigentry %} + +{% configentry "location", "enum", "VELOCITY | BACKEND" %} +Whether the target is the proxy or a backend server. The `VELOCITY` option can only be used for proxies that have `act-as-client` enabled. +{% endconfigentry %} + +You can register the same command on multiple targets. This is useful if you want the command to be available on multiple backends for example: + +```yaml +register: + - id: "hub-1" + location: BACKEND + - id: "survival-1" + location: BACKEND +``` + +--- + +## defaults + +Default execution settings. Every field here is inherited by each entry in `commands` unless that entry explicitly overrides it. Define your common settings once here, and only override where you actually need something different. This keeps your scripts short and clean. + +{% configentry "run-as", "enum", "CONSOLE | PLAYER | OPERATOR" %} +How the command runs on the target server. `CONSOLE` runs as the server console with full permissions. `PLAYER` runs as the triggering player with their own permissions. `OPERATOR` runs as the player but with temporary elevated permissions for that specific command. +{% endconfigentry %} + +{% configentry "execute", "list", "" %} +Where to send the command for execution. This is where the command actually runs, not where it is registered. The distinction matters because you can register a command on Velocity but execute it on a backend. + {% configentry "id", "string", "" %} + The `server-id` or `client-id` of the target. Must match exactly what is in that instance's config. If it doesn't match, the command won't register on that target and you will see a warning. + {% endconfigentry %} + +{% configentry "location", "enum", "VELOCITY | BACKEND" %} +Whether the target is the proxy or a backend server. +{% endconfigentry %} +{% endconfigentry %} + +{% configentry "server", "object", "" %} + Controls player targeting and online/offline scheduling. These only matter when you are executing commands that involve a specific player on a backend. + + {% configentry "target-required", "boolean", "true | false" %} + If `true`, the command is skipped for targets where the resolved player is not currently online. + {% endconfigentry %} + + {% configentry "schedule-online", "boolean", "true | false" %} + If `true`, the command is queued until the target player connects to the backend. The command is saved to disk so it survives server restarts. When the player finally joins that server, the command runs automatically. + {% endconfigentry %} + + {% configentry "player-arg", "string" %} + Overrides which player identity is used for `target-required` and `schedule-online`. + Set this to an argument name (not an argument type), for example `player`. + The referenced argument must be player-resolvable (for example `STRING`, `PLAYERS`, or `OFFLINE_PLAYER`). + {% endconfigentry %} + + {% hint "info" %} + Scheduled commands are saved on the Velocity proxy at `plugins/commandbridge/data/tasks.json`. + They survive restarts. If the script is deleted or renamed before the player connects, the queued task is discarded because there is nothing to execute anymore. + {% endhint %} +{% endconfigentry %} + +{% configentry "delay", "duration", "" %} +Delay before executing. If you set this to `2s`, the command waits 2 seconds before it is dispatched. +{% endconfigentry %} + +{% configentry "cooldown", "duration", "" %} +Per-player cooldown between uses. If a player runs the command and the cooldown is `10s`, they have to wait 10 seconds before they can use it again. Console is never rate-limited. +{% endconfigentry %} + +{% hint "warning" %} +`OPERATOR` gives the player temporary elevated access for that specific command. Only use it when the target command requires permissions the player doesn't normally have. If `CONSOLE` works, use `CONSOLE`. +{% endhint %} + +--- + +## args + +Every command can be configured to have optional or required arguments. Each argument becomes a `${name}` placeholder you can use in your command strings. Arguments are positional, so the first thing the player types after the command goes to the first argument, the second to the second, and so on. + +{% configentry "name", "string", "" %} +The name used as `${name}` in command strings. Use something descriptive like `player` or `amount`, not `a` or `x`. Required. +{% endconfigentry %} + +{% configentry "required", "boolean", "true | false" %} +Whether the player must provide this argument. If `true` and the player skips it, they get an error. +{% endconfigentry %} + +{% configentry "type", "enum", "" %} +The argument type. Controls how the input is parsed and what tab completion the player sees. See [Argument Types](/docs/scripting/argument-types/) for all types. +{% endconfigentry %} + +{% configentry "suggestions", "list", "" %} +Static tab completion suggestions. These are only hints for tab completion, the player can still type anything the argument type accepts. These suggestions are added to the existing ones and do not replace any existing suggestions, such as the `PLAYERS` suggestions. +{% endconfigentry %} + +> Required arguments must come before optional ones. If you put an optional argument before a required one, validation will fail and the script won't load. + +{% hint "warning" %} +`TEXT` and `GREEDY_STRING` must always be the last argument, because they consume everything remaining in the input. You can't put anything after them. +{% endhint %} + +--- + +## commands + +The actual commands to execute. Each entry runs in order when the script is triggered. This is where the action happens. This is a list where the following can be repeated, so multiple commands can be executed. + +{% configentry "command", "string", "" %} +The command to run. Use `${argname}` in the command string for argument placeholders. No leading `/` needed. +{% endconfigentry %} + +> The `command` entry is the only one required. Any field you don't set here is inherited from `defaults`. So if all your commands run as `CONSOLE` on the same backend, you define that once in `defaults` and never touch it in the individual command entries. Only override what needs to be different: + +```yaml +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: false + delay: 0s + cooldown: 0s + +commands: + - command: "eco give ${player} 100" + - command: "say ${player} got 100 coins" + - command: "broadcast Welcome bonus applied!" + execute: + - id: "lobby-1" + location: BACKEND +``` + +The first two commands inherit everything from `defaults` and run on `survival-1`. The third overrides `execute` to target `lobby-1` instead. Clean and simple. + +--- + +## Duration format + +Duration fields (`delay`, `cooldown`, `timeout`) accept human-readable values. You don't have to do math in your head to convert everything to seconds: + +| Format | Meaning | +|--------|---------| +| `500ms` | 500 milliseconds | +| `0s`, `5s`, `30s` | seconds | +| `1m`, `5m` | minutes | +| `1h` | hours | +| `10` | 10 seconds (bare numbers default to seconds) | + +> Parsing is case-insensitive, so `5S` and `5s` both work. diff --git a/src/docs/3.3.7/troubleshooting.md b/src/docs/3.3.7/troubleshooting.md new file mode 100644 index 0000000..c2e359b --- /dev/null +++ b/src/docs/3.3.7/troubleshooting.md @@ -0,0 +1,452 @@ +--- +title: Troubleshooting +order: 8 +description: "Common CommandBridge errors and how to fix them. Covers connection failures, authentication issues, script errors, and WebSocket/Redis problems." +--- + +This page lists real error messages you may encounter and how to fix them. + +--- + +## Connection issues + +### `Connection failed: ` + +The backend could not open a WebSocket connection to Velocity. + +| Cause | Fix | +|-------|-----| +| Wrong host | The backend's `endpoints.websocket.host` must be the IP or hostname where Velocity is reachable. If both run on the same machine, use `127.0.0.1`. If on different machines, use Velocity's external IP or domain. | +| Wrong port | The backend's `endpoints.websocket.port` must match Velocity's `endpoints.websocket.bind-port`. | +| Port not open | The bind port on Velocity must be open as TCP in your firewall. If you are on a hosting provider, check their panel for a separate port allowlist. | +| Velocity not running yet | In WebSocket mode, Velocity hosts the server. If the backend starts before Velocity is listening, the connection has nothing to reach. The backend retries automatically, but if Velocity takes too long you may hit the `reconnect-timeout`. Start Velocity first. | + +--- + +### `WebSocket TLS/HTTP failed to bind on : ()` + +Velocity could not start the WebSocket server on the configured address. + +The most common reason is **"Address already in use"** — another process (or a previous Velocity instance that hasn't fully shut down) is already occupying that port. + +**Fix:** Either wait a few seconds and restart, or change `endpoints.websocket.bind-port` to an unused port on Velocity and update `endpoints.websocket.port` on all backends to match. + +--- + +### `Connection lost - attempting to reconnect` + +The WebSocket connection between a backend and Velocity dropped unexpectedly. This is logged on the backend side. The backend will automatically try to reconnect using the configured `reconnect-timeout` and `reconnect-interval`. + +If the reconnection succeeds, no action is needed. If it keeps failing, see the next entry. + +--- + +### `All reconnection attempts failed after s` + +The backend exhausted its reconnection window (configured by `timeouts.reconnect-timeout`) without successfully reconnecting. + +**Fix:** Check that Velocity is running and reachable from the backend. Verify host, port, firewall, and TLS mode. You can manually trigger a reconnection from the backend with `/cb reconnect` once the underlying issue is resolved. + +--- + +### `Scheduling automatic reconnection (timeout: s, interval: s)` + +Informational. The backend detected a disconnect and is entering the automatic reconnection loop. The values shown come from `timeouts.reconnect-timeout` and `timeouts.reconnect-interval` in the backend config. + +--- + +## Authentication errors + +### `Authentication rejected by server` + +The backend sent an `AUTH_REQUEST` but Velocity responded with `AUTH_FAIL`. This means the shared secret on the backend does not match Velocity's `secret.key`. + +**Fix:** Open `plugins/commandbridge/secret.key` on Velocity, copy its content, and paste it into the backend's `security.secret` config field (or copy the `secret.key` file itself to the backend). Make sure there are no trailing spaces or line breaks. + +--- + +### `Authentication failed: invalid server proof` + +The backend received an `AUTH_OK` from Velocity, but the server's HMAC proof did not verify. This means the backend and Velocity are using different secrets even though the server thinks the client's secret was correct — which should not happen under normal circumstances. + +**Fix:** This typically indicates a corrupted secret on one side. Regenerate: delete `secret.key` on Velocity, restart it to create a new one, then copy the new key to all backends. + +--- + +### `Authentication timeout` + +The backend sent an `AUTH_REQUEST` but received no response within the configured `timeouts.auth-timeout`. + +**Fix:** This usually means Velocity never received the request. Check that the connection was actually established (look for TLS or connection errors above this message in the log). If the connection is fine, increase `timeouts.auth-timeout` if your network has high latency. + +--- + +### `Authentication failed (malformed payload) from ''` + +Velocity received an `AUTH_REQUEST` but the payload was missing required fields (client ID, nonce, or HMAC). This should not happen with matching plugin versions. + +**Fix:** Make sure both Velocity and the backend are running the same version of CommandBridge. + +--- + +### `'security.secret' contains 'change-me'. replace with real key` + +The backend config still has the default placeholder secret. CommandBridge will not authenticate with this value in production. + +**Fix:** Copy the real key from Velocity's `secret.key` file into `security.secret` on the backend. + +--- + +## TLS / SSL errors + +### `TLS pin mismatch (STRICT): expected '' but got ''` + +In STRICT mode, the backend has a configured `tls-pin` that does not match the certificate Velocity is presenting. + +**Fix:** Get the correct SPKI pin from the Velocity startup log (it prints `TLS SPKI pin: sha256/...` on boot) and set it as `security.tls-pin` on the backend. + +--- + +### `TLS pin mismatch (TOFU configured): expected '' but got ''` + +The backend has a `tls-pin` set in its config (from a previous TOFU pinning), but Velocity's certificate has changed since then (e.g. the keystore was regenerated). + +**Fix:** Clear `security.tls-pin` on the backend (set it to `""`) and restart. The backend will re-pin the new certificate on the next connection. Optionally, copy the new pin from the Velocity startup log for persistent pinning. + +--- + +### `TLS pin mismatch (TOFU session): expected '' but got ''` + +During a single session, the backend already pinned a certificate in memory, but on a subsequent reconnection the certificate changed. This could indicate a man-in-the-middle or that Velocity was restarted with a new certificate mid-session. + +**Fix:** Restart the backend so it can pin the new certificate fresh. If you want to prevent this, set the pin explicitly in the config. + +--- + +### `STRICT mode requires a TLS pin` + +The backend is configured with `tls-mode: STRICT` but `tls-pin` is empty. + +**Fix:** STRICT mode always requires an explicit pin. Copy the SPKI pin from the Velocity startup log into `security.tls-pin` on the backend. + +--- + +### `TLS validation failed: empty server certificate chain` + +The backend connected to Velocity but received no TLS certificate. This typically means one side is using `PLAIN` while the other expects TLS. + +**Fix:** Make sure `security.tls-mode` is the same on both Velocity and the backend. + +--- + +### `'tls-mode=PLAIN' disables transport encryption. use TOFU/STRICT in production` + +A warning that you are running without any transport encryption. All traffic between the proxy and backends is sent in plain text. + +**Fix:** For anything beyond local testing, switch to `tls-mode: TOFU` (recommended) or `STRICT` on both sides. + +--- + +### `TLS is disabled. This is insecure for production.` + +Same as above, logged on the Velocity side when the WebSocket server starts without TLS. + +--- + +### STRICT mode keystore errors + +| Error | Fix | +|-------|-----| +| `'security.keystore-path' is required in STRICT mode` | Set the full path to your `.p12` or `.jks` keystore file on Velocity. | +| `'security.keystore-password' is required in STRICT mode` | Set the keystore password on Velocity. | +| `'security.keystore-type' is required in STRICT mode` | Set `PKCS12` or `JKS` on Velocity. | +| `Failed to load STRICT keystore` | The keystore file could not be read. Verify the path exists, the password is correct, and the type matches the file format. | + +See the [Security](/docs/configuration/security/) page for full TLS setup instructions. + +--- + +## Script validation errors + +CommandBridge validates every script at load time. If a script has errors, it is skipped and the problems are printed in the Velocity log as a bulleted list under the script file name. + +### `does not match required pattern: ` + +A field value failed a regex validation. The most common case is the script **name**: it must be lowercase, 3 to 33 characters, start with a letter, and only contain `a-z`, `0-9`, or `-`. + +Valid: `eco-give`, `lobby`, `alert-staff` +Invalid: `EcoGive`, `e`, `my_command`, `123test` + +--- + +### Required field missing + +A field marked as required was not set or was left empty. The core required fields in a script are: `version`, `name`, `permissions`, `register`, `defaults`, `args`, and `commands`. Check the error message for which specific field is missing. + +--- + +### `Argument '' (type '') is only supported on [], but script registers on []` + +You used a platform-specific argument type in a script that registers on a location where that type is not available. For example, `PLAYERS` on Velocity requires PacketEvents; without it, validation fails. + +**Fix:** Either change `register` to a backend, or use a universal argument type like `STRING`. + +--- + +### `Required argument '' is used after optional argument '' in command string` + +Required arguments must come before optional arguments in the `args` list. If you have an optional argument followed by a required one, the parser cannot determine where one ends and the other begins. + +**Fix:** Reorder your `args` so all required arguments come first. + +--- + +### `Argument '' (type '') must be the last argument in command string` + +Argument types that consume all remaining input (like `GREEDY_STRING` or `TEXT`) must be the final argument. Nothing can follow them because they absorb everything. + +**Fix:** Move this argument to the end of the `args` list. + +--- + +### `Unknown argument '' at placeholder[]` + +A `${name}` placeholder in a command string references an argument name that does not exist in the `args` list. + +**Fix:** Check spelling and case — placeholder names are case-sensitive. `${Player}` is not the same as `${player}`. + +--- + +### `Empty placeholder ${} is not allowed` + +A command string contains `${}` with nothing inside. + +**Fix:** Fill in the argument name: `${player}`, `${amount}`, etc. + +--- + +### `Invalid default '': ` + +A default value defined for an argument could not be parsed as the expected type. + +**Fix:** Make sure the default value is valid for the argument type (for example a number for `INTEGER` or `DOUBLE`, or `true/false` for `BOOLEAN`). + +--- + +### YAML parse errors + +| Error | Meaning | +|-------|---------| +| `YAML error at line , column : ` | The YAML syntax is broken at the indicated position. Common causes: wrong indentation, tabs instead of spaces, missing colons, unclosed quotes. | +| `expected a mapping/object` | A field expected a YAML map (key-value pairs) but got something else (a string, a list, etc.). | +| `expected a list` | A field expected a YAML list but got a scalar or map. | +| `expected a value` | A required value is missing entirely. | + +--- + +## Configuration errors + +### `Invalid config.yml` + +The config file could not be parsed at all. This is usually a YAML syntax error. + +**Fix:** Validate your `config.yml` with a YAML linter. Look for wrong indentation, tabs, or missing colons. + +--- + +### `Unknown config key: '' did you mean '' ? (ignored)` + +A key in your config does not match any known setting. CommandBridge suggests the closest match if one exists. + +**Fix:** Rename the key to the suggested value, or remove it if it is no longer needed. + +--- + +### `Invalid value '' for ''. expected one of ` + +An enum field (like `tls-mode` or `endpoint-type`) has an unrecognized value. + +**Fix:** Use one of the listed valid values. Common examples: `tls-mode` accepts `PLAIN`, `TOFU`, or `STRICT`. `endpoint-type` accepts `WEBSOCKET` or `REDIS`. + +--- + +### Host format warnings + +| Error | Fix | +|-------|-----| +| `'endpoints.websocket.host' must NOT include ws:// or wss://` | Remove the protocol prefix. Use `127.0.0.1`, not `ws://127.0.0.1`. | +| `'endpoints.redis.host' must NOT include redis:// or rediss://` | Remove the protocol prefix. Use `127.0.0.1`, not `redis://127.0.0.1`. | + +--- + +### Port and timeout validation + +| Error | Fix | +|-------|-----| +| `'endpoints.websocket.port' must be between 1 and 65535` | Set a valid port number. | +| `'endpoints.redis.port' must be between 1 and 65535` | Set a valid port number. | +| `'timeouts.auth-timeout' must be > 0` | The authentication timeout must be a positive value. | +| `'timeouts.reconnect-timeout' must be > 0` | The reconnection window must be a positive value. | +| `'timeouts.reconnect-interval' must be > 0` | The reconnection retry interval must be a positive value. | +| `'timeouts.register-timeout' must be > 0` | The command registration timeout must be a positive value (Velocity only). | +| `'timeouts.ping-timeout' must be > 0` | The ping timeout must be a positive value (Velocity only). | + +--- + +## Command execution issues + +### Commands are registered but nothing happens + +`/cb list` shows the backend as connected, but running a script command produces no output or effect. + +**Checklist:** + +1. **Script not loaded** — run `/cb scripts` on Velocity to check if the script appears. If it doesn't, there is a validation error. Check the Velocity log for the exact problem. +2. **Script disabled** — the script might have `enabled: false`. Check the `enabled` field. +3. **Scripts live on Velocity** — all script files must be in `plugins/commandbridge/scripts/` on the Velocity proxy. Do not put scripts on backends. +4. **Target client ID mismatch** — the `id` in `execute` must match the backend's `client-id` exactly. A typo means the command is sent to a nonexistent client and silently fails. +5. **Client disconnected** — the target backend may have disconnected after you last checked. Run `/cb list` again. + +--- + +### `Target '' () not found or not connected` + +A command tried to execute on a backend that is not currently connected. + +**Fix:** Check that the backend is running and authenticated (`/cb list`). Verify the `client-id` in the backend config matches the `id` in the script's `execute` block. + +--- + +### `target-required: player '' is not on '', skipping` + +The command has `target-required: true` and the player is not on the target server. The execution is skipped for that target. + +This is expected behavior when using `target-required` to restrict commands to the server the player is currently on. If you want the command to execute regardless of where the player is, set `target-required: false`. + +--- + +### `Command '' has no execution targets defined` + +The script's `commands` section has an entry with no `execute` blocks, so there is nothing to dispatch. + +**Fix:** Add at least one `execute` block with an `id` and `command` to the command entry. + +--- + +### `You do not have permission to execute this command` + +The player does not have the required permission node. + +**Fix:** + +1. Grant `commandbridge.command.` where the command is **registered** (Velocity or the backend, depending on the script's `register` setting). +2. Or set `permissions.enabled: false` in the script to disable permission checks entirely. +3. Or set `permissions.silent: true` to suppress the message (the command still won't run, but the player won't see the error). + +--- + +## Redis errors + +### `Redis connection failed: ` + +The backend could not connect to the Redis server. + +**Fix:** Verify that the Redis server is running and reachable at the configured `endpoints.redis.host` and `endpoints.redis.port`. Check firewall rules. If Redis requires authentication, make sure credentials are correct. + +--- + +### `Redis subscriber disconnected: ` + +The Redis pub/sub subscription dropped. The backend will attempt to reconnect. + +**Fix:** Check the Redis server health. If this keeps happening, look at Redis server logs for memory or connection limit issues. + +--- + +### `Redis publish failed: ` + +A message could not be published to Redis. + +**Fix:** Check the Redis connection. This usually means the connection was lost. The backend should reconnect automatically. + +--- + +### `Redis subscribe loop failed: ` + +Velocity's Redis subscription loop encountered an error. This is logged on the Velocity side. + +**Fix:** Same as above — check the Redis server. Velocity will log this when the subscription thread fails. + +--- + +## Platform and startup errors + +### `Unknown platform detected (). Falling back to Bukkit adapter` + +CommandBridge could not identify the server platform and is using the generic Bukkit adapter. This is a warning — things may still work, but some platform-specific features might not be available. + +--- + +### `Failed to enable CommandBridge: ` + +The plugin failed during startup. The error message contains the specific cause. + +**Fix:** Check the full stack trace in the server log. Common causes: config parse errors, port conflicts, missing dependencies. + +--- + +### `Detected old CommandBridge installation at ''. Please view the migration guide` + +An older version's data folder was found alongside the new one. + +**Fix:** Remove the old installation folder and reconfigure CommandBridge using the current documentation. + +--- + +### `PapiProxyBridge not found. PlaceholderAPI will not be used` + +Velocity cannot resolve `%placeholder%` style PlaceholderAPI placeholders because PapiProxyBridge is not installed. + +**Fix:** If you need PAPI placeholders, install [PapiProxyBridge](https://hangar.papermc.io/William278/PapiProxyBridge) on Velocity and PlaceholderAPI on your backends. If you don't use PAPI placeholders, this warning is safe to ignore. + +--- + +### `A new version of CommandBridge is available` + +A newer release exists. This is informational. + +--- + +## Debug mode + +Enable debug logging for verbose output: + +```yaml +debug: true +``` + +Or toggle it at runtime without a restart: + +``` +/cb debug +``` + +Debug logs include network frames, authentication handshakes, command dispatch steps, placeholder resolution, and registration events. + +{% hint "warning" %} +Debug mode produces a lot of log output. It is meant for diagnosing specific issues, not for production use. Turn it off when you are done. +{% endhint %} + +--- + +## Getting help + +If you went through this page and are still stuck: + +1. Run `/cb dump` and include the output +2. Include your CommandBridge version, Minecraft version, Velocity version, and backend platform (Paper, Folia, etc.) +3. Include relevant log output (use a paste service for long logs, don't paste them directly into chat) +4. Describe what you expected to happen and what actually happened + +- [Discord](https://discord.gg/QPqBYb44ce) +- [GitHub Issues](https://github.com/objz/CommandBridge/issues) diff --git a/src/editor.njk b/src/editor.njk new file mode 100644 index 0000000..89ec6ea --- /dev/null +++ b/src/editor.njk @@ -0,0 +1,160 @@ +--- +layout: layouts/base.njk +title: Script Editor +permalink: /editor/ +description: "Build CommandBridge scripts as a node graph, live on your proxy." +hideFooter: true +eleventyExcludeFromCollections: true +appPage: true +--- + + + + + + + + +

+
+
+ + +
+ + +
+ + + + + +
+ + + +
+ + +
+ + + + +
+ +
+ +
+
+ +
+ +
+ +
+ + + + +
+
+ + {% macro menuItem(v) %} + + {% endmacro %} + {% macro menuEntry(v) %} + + + + + {% endmacro %} + + + +
+ Confirm on the server to save + + + +
+ +
+ + + + +
+ +
+ + + +
+
+

Edit your scripts

+

Run /cb editor on your proxy and open the link it gives you.

+
+
+
+ +
diff --git a/src/lib/editor/editor.css b/src/lib/editor/editor.css new file mode 100644 index 0000000..75d41b7 --- /dev/null +++ b/src/lib/editor/editor.css @@ -0,0 +1,509 @@ +/* CommandBridge script editor. The app chrome follows the site; the graph itself is colorful on purpose. */ + +.ed, .ph-ghost { + --pop-bg: rgb(24 24 27 / .97); --hover: rgb(255 255 255 / .07); --line-soft: rgb(255 255 255 / .09); + --piece-fg: #7dd3fc; --call-fg: #c4b5fd; --code-bg: #111113; --code-side: #0c0c0e; --code-fg: #d4d4d8; --switch-off: #52525b; + --y-str: #86efac; --y-num: #fcd34d; --y-bool: #f9a8d4; --y-punct: #52525b; --y-com: #71717a; +} +.ed { + --c-flow: #e5e7eb; --c-arg: #38bdf8; --c-srv: #f59e0b; --c-call: #a78bfa; --c-ok: #22c55e; --c-bad: #f87171; --c-focus: #60a5fa; + --h-command: #6d28d9; --h-run: #15803d; --h-wait: #475569; --h-arg: #0369a1; --h-server: #b45309; + --h-alias: #be185d; --h-defaults: #0f766e; --h-runas: #a16207; --h-cooldown: #475569; --h-rules: #c2410c; + --c-alias: #f472b6; --c-def: #2dd4bf; --c-runas: #facc15; --c-cool: #cbd5e1; --c-rules: #fb923c; + --node-bg: #18181b; --node-fg: #f4f4f5; --node-muted: #a1a1aa; --node-line: #3f3f46; --field-bg: #09090b; + display: flex; flex-direction: column; height: calc(100vh - 64px); + user-select: none; /* drags would otherwise select page text, which flickers in light mode */ +} + +/* ---------------- toolbar ---------------- */ +.ed-bar { display: flex; align-items: center; gap: 10px; padding: 0 12px; height: 52px; border-bottom: 1px solid hsl(var(--border)); font-size: 14px; } +.ed-seg { display: flex; align-items: center; gap: 2px; padding: 2px; border: 1px solid hsl(var(--border)); border-radius: 8px; } +.ed-tool { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 32px; height: 30px; padding: 0 8px; border: none; border-radius: 6px; + background: none; color: hsl(var(--muted-foreground)); cursor: pointer; } +.ed-tool:hover:not(:disabled) { color: hsl(var(--foreground)); background: hsl(var(--accent) / .6); } +/* switched on: filled and ringed, so it reads differently from a hover */ +.ed-tool[aria-pressed="true"] { color: hsl(var(--background)); background: hsl(var(--foreground)); } +.ed-tool[aria-pressed="true"]:hover { color: hsl(var(--background)); background: hsl(var(--foreground) / .85); } +.ed-tool:disabled { opacity: .35; cursor: default; } +.ed-btn { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 12px; border-radius: 8px; font-size: 13px; font-weight: 500; + border: 1px solid hsl(var(--border)); background: hsl(var(--background)); color: hsl(var(--foreground)); cursor: pointer; } +.ed-btn:hover:not(:disabled) { background: hsl(var(--accent)); } +.ed-btn:disabled { opacity: .4; cursor: default; } +.ed-spacer { flex: 1; } +.ed-status { color: hsl(var(--muted-foreground)); } +.ed-status-err { color: #ef4444; } +.ed-save { height: 34px; padding: 0 16px; border-radius: 8px; font-weight: 600; font-size: 13px; border: 1px solid transparent; + background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); cursor: pointer; } +.ed-save:disabled { background: none; border-color: hsl(var(--border)); color: hsl(var(--muted-foreground)); cursor: default; } + +/* ---------------- work area ---------------- */ +.ed-work { position: relative; flex: 1; display: flex; min-height: 0; } +.ed-landing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; text-align: center; + font-size: 1rem; line-height: 1.5; color: hsl(var(--muted-foreground)); background: hsl(var(--background)); } +/* same as the setup page's notice */ +.ed-landing > div { max-width: 28rem; } +.ed-landing code { font-size: 0.9em; } +.ed-landing-t { margin-bottom: 0.5rem; font-size: 1.5rem; font-weight: 600; letter-spacing: -0.03em; color: hsl(var(--foreground)); } +.ed-stage { position: relative; flex: 1; display: flex; min-width: 0; } +.ed-keys { position: absolute; left: 0; top: 0; width: 1px; height: 1px; padding: 0; border: 0; opacity: 0; resize: none; pointer-events: none; } +/* the YAML panel: files on the left with what saving does to each, the chosen file on the right */ +.ed-yaml { position: relative; display: flex; width: var(--yaml-w, 46%); min-width: 320px; max-width: 80%; border-left: 1px solid hsl(var(--border)); background: var(--code-bg); color: var(--node-fg); font-size: 13px; } +/* the panel's left edge is dragged to resize it */ +.ed-yaml-grip { position: absolute; left: -4px; top: 0; bottom: 0; z-index: 6; width: 8px; cursor: ew-resize; } +.ed-yaml-grip:hover, .ed-yaml-grip.on { background: linear-gradient(90deg, transparent 3px, var(--c-focus) 3px, var(--c-focus) 5px, transparent 5px); } +.ed-files { width: 180px; flex-shrink: 0; overflow: auto; padding: 8px 6px; border-right: 1px solid var(--hover); background: var(--code-side); } +.ed-files-h { display: flex; align-items: center; gap: 6px; padding: 4px 8px 8px; font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: #71717a; } +.ed-file { display: flex; align-items: center; gap: 7px; width: 100%; height: 28px; padding: 0 8px; border: none; border-radius: 6px; background: none; color: #a1a1aa; text-align: left; cursor: pointer; } +.ed-file:hover { background: var(--hover); color: var(--node-fg); } +.ed-file.on { background: var(--line-soft); color: var(--node-fg); } +.ed-file-i { display: flex; flex-shrink: 0; } +.ed-file-n { flex: 1; min-width: 0; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ed-file small { font-size: 10.5px; } +.ed-file-s { width: 14px; text-align: center; font: 600 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.ed-file.new { color: #86efac; } .ed-file.new .ed-file-s { color: #4ade80; } +.ed-file.changed { color: #fde68a; } .ed-file.changed .ed-file-s { color: #fbbf24; } +.ed-file.deleted { color: #fca5a5; } .ed-file.deleted .ed-file-s { color: #f87171; } .ed-file.deleted .ed-file-n { text-decoration: line-through; } +.ed-yaml-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.ed-yaml-main header { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 40px; padding: 0 14px; border-bottom: 1px solid var(--hover); + font: 600 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; } +.ed-yaml-main header small { font: 500 11.5px system-ui, sans-serif; color: #71717a; } +.ed-yaml-body { flex: 1; display: flex; overflow: auto; font: 12.5px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; } +.ed-yaml-n { margin: 0; padding: 12px 0; min-width: 46px; text-align: right; color: var(--node-line); user-select: none; position: sticky; left: 0; background: var(--code-bg); } +.ed-yaml-n span { display: inline-block; width: 100%; padding-right: 10px; border-right: 2px solid transparent; } +.ed-yaml-n span.chg { color: #fbbf24; border-right-color: #fbbf24; } +.ed-yaml-n span.add { color: #4ade80; border-right-color: #4ade80; } +.ed-yaml-n span.del { color: #f87171; border-right-color: #f87171; } +/* the YAML is typed into a see-through field over its own highlighting */ +.ed-yaml-edit { position: relative; flex: 1; min-width: max-content; } +.ed-yaml-code, .ed-yaml-ta { margin: 0; padding: 12px 16px; font: inherit; line-height: inherit; white-space: pre; tab-size: 2; } +.ed-yaml-code { color: var(--code-fg); } +/* problems underlined like in an editor: red for errors, yellow for keys the proxy doesn't know */ +.ed-yaml-lint { position: absolute; inset: 0; margin: 0; padding: 12px 16px; font: inherit; line-height: inherit; white-space: pre; color: transparent; pointer-events: none; } +.lint-e, .lint-w { text-decoration: underline wavy #f87171; text-decoration-thickness: 1.5px; text-underline-offset: 3px; text-decoration-skip-ink: none; } +.lint-w { text-decoration-color: #fbbf24; } +.ed-yaml-n span.le { color: #f87171; cursor: help; } .ed-yaml-n span.lw { color: #fbbf24; cursor: help; } +/* the completion list at the caret */ +.ed-ycmp { position: absolute; z-index: 5; min-width: 200px; max-height: 240px; overflow: auto; padding: 4px; border-radius: 8px; border: 1px solid var(--line-soft); + background: var(--pop-bg); box-shadow: 0 12px 30px rgb(0 0 0 / .4); animation: ed-pop-in .12s cubic-bezier(.2, .9, .3, 1.1); transform-origin: 0 0; } +.ed-ycmp button { display: flex; justify-content: space-between; gap: 18px; width: 100%; padding: 3px 8px; border: none; border-radius: 5px; background: none; color: var(--node-fg); font: inherit; text-align: left; cursor: pointer; } +.ed-ycmp button.on, .ed-ycmp button:hover { background: rgb(96 165 250 / .2); } +.ed-ycmp small { font: 11px system-ui, sans-serif; color: var(--node-muted); } +.ed-yaml-ta { position: absolute; inset: 0; width: 100%; height: 100%; resize: none; overflow: hidden; border: none; outline: none; background: none; + color: transparent; caret-color: var(--node-fg); user-select: text; -moz-user-select: text; } +.ed-yaml-ta::selection { background: rgb(96 165 250 / .35); color: transparent; } +.ed-yaml-ta[readonly] { caret-color: transparent; } +.ed-yaml-err { margin: 0; padding: 7px 14px; font-size: 12px; color: #fca5a5; background: rgb(248 113 113 / .1); border-bottom: 1px solid rgb(248 113 113 / .25); } +.y-key { color: var(--call-fg); } .y-punct { color: var(--y-punct); } .y-str { color: var(--y-str); } .y-num { color: var(--y-num); } .y-bool { color: var(--y-bool); } .y-enum { color: var(--piece-fg); } +.y-com { color: var(--y-com); font-style: italic; } +.y-arg { color: var(--piece-fg); } + +.ed-corner { position: absolute; right: 16px; bottom: 16px; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; z-index: 5; } +#ed-minimap { width: 240px; height: 150px; border: 1px solid hsl(var(--border)); border-radius: 8px; background: hsl(var(--background) / .85); color: hsl(var(--foreground)); cursor: pointer; } +.ed-zoom { display: flex; align-items: center; gap: 2px; padding: 2px; font-size: 12px; border: 1px solid hsl(var(--border)); border-radius: 8px; + background: hsl(var(--background)); color: hsl(var(--muted-foreground)); } +.ed-zoom button { display: inline-flex; align-items: center; padding: 4px 9px; border: none; background: none; color: inherit; cursor: pointer; border-radius: 6px; } +.ed-zoom button:hover { color: hsl(var(--foreground)); background: hsl(var(--accent)); } +.ed-zoom span { min-width: 40px; text-align: center; } +.ed-notes { position: absolute; left: 16px; bottom: 16px; z-index: 5; max-width: min(600px, calc(100% - 320px)); max-height: 30%; overflow: auto; padding: 10px 12px; + font-size: 13px; line-height: 1.6; border: 1px solid hsl(var(--border)); border-radius: 8px; background: hsl(var(--background)); } +.ed-more { padding: 0; border: none; background: none; font: inherit; color: hsl(var(--muted-foreground)); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } +.ed-ok { color: var(--c-ok); } .ed-err { color: #ef4444; } .ed-warn { color: var(--c-srv); } .ed-muted { color: hsl(var(--muted-foreground)); } + +/* port and context menus */ +.ed-pop { position: absolute; z-index: 20; width: 220px; padding: 5px; font-size: 13px; + border: 1px solid var(--line-soft); border-radius: 12px; background: var(--pop-bg); color: var(--node-fg); + box-shadow: 0 1px 0 var(--hover) inset, 0 18px 40px rgb(0 0 0 / .55), 0 2px 6px rgb(0 0 0 / .3); backdrop-filter: blur(12px); } +.ed-pop-t { padding: 6px 10px 7px; margin-bottom: 4px; font-size: 12px; font-weight: 600; color: var(--node-fg); border-bottom: 1px solid var(--hover); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ed-pop-s { padding: 8px 10px 4px; font-size: 10.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--node-muted); } +.ed-pop hr { margin: 4px 6px; border: none; border-top: 1px solid var(--hover); } +.ed-mw { position: relative; } +.ed-mi { display: flex; width: 100%; align-items: center; gap: 10px; height: 32px; padding: 0 8px; text-align: left; + border: none; border-radius: 7px; background: none; color: inherit; cursor: pointer; } +/* the top level highlights the item aim() opened, not every item the pointer crosses on its way to a submenu */ +.ed-sub .ed-mi:hover:not(:disabled), .ed-pop > .ed-mw.open > .ed-mi:not(:disabled) { background: var(--hover); } +.ed-pop.scroll { max-height: min(460px, calc(100% - 24px)); overflow: auto; } +.ed-mi-h { max-width: 45%; font-size: 12px; color: var(--node-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ed-mi-ok { display: flex; color: var(--c-focus); } +.ed-mi.checked:disabled { opacity: 1; } +.ed-mi:disabled { opacity: .45; cursor: default; } +.logo { width: 16px; height: 16px; object-fit: contain; flex-shrink: 0; } +.ed-mi.muted .logo { filter: grayscale(1); opacity: .6; } +.ed-mi-i { display: flex; flex-shrink: 0; width: 16px; color: var(--c, var(--node-muted)); } +.ed-mi-i:empty { display: none; } +.ed-mi-l { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ed-mi.muted .ed-mi-l { color: var(--node-muted); } +.ed-mi.muted .ed-mi-i { color: var(--node-muted); opacity: .6; } +.ed-mi kbd { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--node-muted); } +.ed-mi-more { color: var(--node-muted); display: flex; } +.ed-mi.danger { color: #fca5a5; } +.ed-mi.danger:hover:not(:disabled) { background: rgb(220 38 38 / .18); } +.ed-mi.danger .ed-mi-i { color: #f87171; } +/* submenus fly out to the side; which one is open is decided by the safe triangle in aim() */ +.ed-sub { display: none; position: absolute; top: -6px; left: calc(100% + 4px); width: 220px; max-height: 60vh; overflow: auto; padding: 5px; + border: 1px solid var(--line-soft); border-radius: 12px; background: var(--pop-bg); box-shadow: 0 18px 40px rgb(0 0 0 / .55); } +.ed-mw.open > .ed-sub { display: block; } +.ed-pop.flip .ed-sub { left: auto; right: calc(100% + 4px); } +/* a server id that isn't listed: type it, then add it as a backend or a proxy */ +.ed-custom { display: flex; gap: 4px; padding: 3px; } +.ed-custom input { flex: 1; min-width: 0; height: 28px; padding: 0 8px; font-size: 13px; border-radius: 6px; border: 1px solid var(--line-soft); background: rgb(0 0 0 / .3); color: var(--node-fg); } +.ed-custom input:focus { outline: none; border-color: var(--c-focus); } +.ed-custom input.bad { border-color: var(--c-bad); } +.ed-custom button { display: grid; place-items: center; width: 28px; height: 28px; border: none; border-radius: 6px; background: none; color: var(--c); cursor: pointer; } +.ed-custom button:hover { background: var(--hover); } + +/* hover reasons for marked fields, and for the save button */ +.ed { position: relative; } +.ed-tip { position: absolute; z-index: 40; max-width: 340px; padding: 7px 10px; font-size: 12.5px; line-height: 1.5; white-space: pre-line; pointer-events: none; + border-radius: 8px; border: 1px solid var(--line-soft); background: var(--pop-bg); color: var(--node-fg); box-shadow: 0 10px 28px rgb(0 0 0 / .5); } +.ed-tip.err { border-color: rgb(248 113 113 / .5); } +.ed-savewrap { display: inline-flex; } +.ed-save:disabled { pointer-events: none; } +.ed-save.blocked { border-color: rgb(248 113 113 / .6); color: #fca5a5; } +.ed-savewrap:has(.blocked) { cursor: pointer; } +.ed-wire-x { position: absolute; z-index: 20; display: grid; place-items: center; width: 30px; height: 30px; transform: translate(-50%, -50%); border-radius: 999px; + border: 1px solid var(--node-line); background: #dc2626; color: #fff; cursor: pointer; box-shadow: 0 4px 12px rgb(0 0 0 / .4); } +.ed-wire-x:hover { background: #b91c1c; } + +/* ---------------- canvas and cursors ---------------- */ +#ed-canvas { flex: 1; position: relative; overflow: hidden; cursor: default; + background-color: hsl(var(--background)); + background-image: radial-gradient(hsl(var(--border)) 1px, transparent 1px); background-size: 24px 24px; } +#ed-canvas.ed-space, #ed-canvas.ed-grab { cursor: grab; } +#ed-canvas.ed-panning, #ed-canvas.ed-panning * { cursor: grabbing !important; } +#ed-canvas.ed-moving, #ed-canvas.ed-moving * { cursor: grabbing !important; } +#ed-canvas.ed-selecting, #ed-canvas.ed-wiring, #ed-canvas.ed-wiring * { cursor: crosshair !important; } +#ed-canvas.ed-resizing, #ed-canvas.ed-resizing * { cursor: nwse-resize !important; } +.ed-marquee { position: absolute; z-index: 10; pointer-events: none; border: 1px solid var(--c-focus); background: rgb(96 165 250 / .12); border-radius: 2px; } + +/* ---------------- nodes ---------------- */ +#ed-canvas .drawflow-node { + width: 380px; padding: 0; align-items: flex-start; font-size: 13px; cursor: default; + border-radius: 10px; border: 1px solid var(--node-line); background: var(--node-bg); color: var(--node-fg); + box-shadow: 0 6px 18px rgb(0 0 0 / .35); +} +#ed-canvas .drawflow-node.selected { border-color: var(--node-line); background: var(--node-bg); } +/* the node being worked on is drawn on top, so a section opening up is never hidden behind a neighbour */ +#ed-canvas .drawflow-node.is-sel { z-index: 4; border-color: var(--c-focus); box-shadow: 0 0 0 2px rgb(96 165 250 / .45), 0 6px 18px rgb(0 0 0 / .35); } +#ed-canvas .drawflow-node .drawflow_content_node { width: 100%; } +/* fixed widths like in Unreal: content wraps downwards instead of pushing into neighbours */ +#ed-canvas .cb-command { width: 420px; } #ed-canvas .cb-run { width: 440px; } #ed-canvas .cb-wait { width: 240px; } +#ed-canvas .cb-arg { width: 380px; } #ed-canvas .cb-server { width: 240px; } +#ed-canvas .cb-alias, #ed-canvas .cb-cooldown { width: 280px; } #ed-canvas .cb-defaults, #ed-canvas .cb-rules { width: 380px; } #ed-canvas .cb-runas { width: 330px; } + +/* the script's enabled switch sits in the title bar; a disabled script fades with everything only it uses */ +#ed-canvas .nh-on { margin-left: auto; display: flex; cursor: default; } +#ed-canvas .nh-on .sw input:checked + span { background: rgb(255 255 255 / .35); } +#ed-canvas .drawflow-node.muted { opacity: .38; filter: saturate(.25); } +#ed-canvas .connection.muted .main-path { opacity: .2; } +#ed-canvas .req { margin-left: 2px; font-style: normal; color: var(--c-bad); } +#ed-canvas .pr .missing { color: var(--c-bad); } + +/* run as: a segmented switch */ +#ed-canvas .seg { display: flex; width: 100%; padding: 3px; gap: 3px; border-radius: 8px; background: var(--field-bg); border: 1px solid var(--node-line); } +#ed-canvas .seg label { flex: 1; position: relative; cursor: pointer; } +#ed-canvas .seg input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; } +#ed-canvas .seg span { display: block; padding: 5px 0; text-align: center; font-size: 12px; font-weight: 600; border-radius: 6px; color: var(--node-muted); } +/* the chosen option's pill slides over, like iOS segmented controls */ +#ed-canvas .seg { position: relative; } +#ed-canvas .seg-thumb { position: absolute; top: 3px; bottom: 3px; left: 3px; width: calc((100% - 6px - (var(--n) - 1) * 3px) / var(--n)); border-radius: 6px; + background: var(--seg, var(--h-runas)); box-shadow: 0 1px 3px rgb(0 0 0 / .3); transition: transform .32s cubic-bezier(.3, 1.25, .4, 1); } +#ed-canvas .seg:has(label:nth-of-type(2) input:checked) .seg-thumb { transform: translateX(calc(100% + 3px)); } +#ed-canvas .seg:has(label:nth-of-type(3) input:checked) .seg-thumb { transform: translateX(calc(200% + 6px)); } +#ed-canvas .seg span { position: relative; transition: color .2s; } +#ed-canvas .seg input:checked + span { color: #fff; } +#ed-canvas .cb-defaults .seg { --seg: var(--h-defaults); } + +/* server: the id is the title; a proxy is blue, like in the menus */ +#ed-canvas .srv { display: none; } +#ed-canvas .cb-server.proxy .nh { background: #1d4ed8; } +#ed-canvas .srv-k { font-size: 12px; color: var(--node-muted); } + +/* aliases: one row per alias, then a field to add one */ +/* aliases: a grouped list, each row editable in place, the last row adds one */ +#ed-canvas .al-list { border-radius: 8px; border: 1px solid var(--node-line); background: var(--field-bg); overflow: hidden; } +#ed-canvas .tag, #ed-canvas .al-new { display: flex; align-items: center; height: 34px; padding: 0 4px 0 10px; border-bottom: 1px solid var(--node-line); } +#ed-canvas .al-new { border-bottom: none; } +#ed-canvas .tag i, #ed-canvas .al-new i { width: 12px; font-style: normal; color: var(--node-muted); } +#ed-canvas .tag input, #ed-canvas .al-new input { flex: 1; min-width: 0; height: 100%; padding: 0 4px; font-size: 13.5px; border: none; background: none; color: var(--node-fg); } +#ed-canvas .tag input { font-weight: 600; } +#ed-canvas .tag input:focus, #ed-canvas .al-new input:focus { outline: none; } +#ed-canvas .tag:focus-within, #ed-canvas .al-new:focus-within { box-shadow: inset 0 0 0 1px var(--c-focus); } +#ed-canvas .al-new:focus-within { border-radius: 0 0 7px 7px; } +#ed-canvas .als .tag:first-child { border-radius: 7px 7px 0 0; } +#ed-canvas .als:empty + .al-new:focus-within { border-radius: 7px; } +#ed-canvas .tag-x { display: grid; place-items: center; width: 26px; height: 26px; border: none; border-radius: 6px; background: none; color: var(--node-muted); cursor: pointer; opacity: 0; transition: opacity .12s; } +#ed-canvas .tag-x svg { position: static; } +#ed-canvas .tag:hover .tag-x, #ed-canvas .tag-x:focus-visible { opacity: 1; } +#ed-canvas .tag-x:hover { color: #fca5a5; background: rgb(248 113 113 / .12); } +#ed-canvas .tag.bad { box-shadow: inset 0 0 0 1px var(--c-bad); } +#ed-canvas .tag input.bad { color: #fca5a5; box-shadow: none !important; } + +/* dropdowns in the menu style */ +#ed-canvas .pick { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; height: 32px; padding: 0 8px 0 10px; font-size: 13px; text-align: left; + border-radius: 7px; border: 1px solid var(--node-line); background: var(--field-bg); color: var(--node-fg); cursor: pointer; } +#ed-canvas .pick:hover { border-color: #52525b; } +#ed-canvas .pick svg { position: static; flex-shrink: 0; color: var(--node-muted); } +#ed-canvas .pick .pi { display: flex; color: var(--node-muted); } +#ed-canvas .pick span { flex: 1; } +#ed-canvas .pick.bad { border-color: var(--c-bad); } +#ed-canvas .fr.idle { opacity: .4; } +#ed-canvas .fr.idle .pick { pointer-events: none; } +#ed-canvas .fl [data-tip] { text-decoration: underline dotted rgb(255 255 255 / .3); text-underline-offset: 3px; cursor: help; } + +/* an unwired step setting shows what it gets from the Defaults */ +#ed-canvas .pr .inh { margin-left: 8px; font-style: normal; font-size: 11.5px; color: #71717a; } +#ed-canvas .nk em { margin-left: 8px; font-style: normal; font-weight: 500; opacity: .75; } + +/* the step's command: text with argument pieces, wrapping downwards */ +#ed-canvas .cmd { min-height: 34px; padding: 5px 9px; font: 13px/24px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; + border-radius: 7px; border: 1px solid var(--node-line); background: var(--field-bg); color: var(--node-fg); cursor: text; } +#ed-canvas .cmd:focus { outline: none; border-color: var(--c-focus); } +#ed-canvas .cmd:empty::before { content: attr(data-placeholder); color: var(--node-muted); } +#ed-canvas .ph { display: inline-block; vertical-align: top; height: 22px; margin: 1px 1px; line-height: 22px; padding: 0 2px 0 7px; border-radius: 5px; font-family: inherit; cursor: default; + background: rgb(56 189 248 / .18); color: var(--piece-fg); box-shadow: 0 0 0 1px rgb(56 189 248 / .35) inset; user-select: none; } +#ed-canvas .ph.bad { background: rgb(248 113 113 / .18); color: #fca5a5; box-shadow: 0 0 0 1px var(--c-bad) inset; } +#ed-canvas .args { display: flex; flex-wrap: wrap; gap: 5px; } +#ed-canvas .args:empty { display: none; } +#ed-canvas .pill { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 9px 0 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; + border-radius: 5px; background: rgb(56 189 248 / .1); color: var(--piece-fg); cursor: grab; } +#ed-canvas .pill::before { content: ""; width: 6px; height: 12px; opacity: .6; + background: radial-gradient(currentColor 1px, transparent 1.2px) 0 0 / 3px 4px; } +#ed-canvas .pill:active { cursor: grabbing; } +#ed-canvas .ph { cursor: grab; padding-right: 7px; } +#ed-canvas .ph:active { cursor: grabbing; } +/* dragging: the text makes room where the piece will go, the piece pops in when dropped */ +#ed-canvas .cmd.drop-here { border-color: rgb(56 189 248 / .7); box-shadow: 0 0 0 3px rgb(56 189 248 / .15); } +#ed-canvas .ph.preview { pointer-events: none; color: transparent; background: rgb(56 189 248 / .12); box-shadow: 0 0 0 1px rgb(56 189 248 / .6) inset; + animation: ph-room .16s cubic-bezier(.2, .8, .2, 1); } +@keyframes ph-room { from { max-width: 0; padding: 0; margin: 0; opacity: 0; } to { max-width: 240px; opacity: 1; } } +#ed-canvas .pill.lifted { opacity: .35; } +/* the dragged piece: the same size and look as in the text, it only follows the pointer */ +.ph-ghost { position: fixed; z-index: 100; height: 22px; padding: 0 7px; line-height: 22px; border-radius: 5px; pointer-events: none; transform-origin: 0 0; + font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; background: #102c3b; color: var(--piece-fg); box-shadow: 0 0 0 1px rgb(56 189 248 / .35) inset, 0 8px 22px rgb(0 0 0 / .4); + transition: background .15s, color .15s, opacity .15s; } +.ph-ghost.doomed { background: rgb(248 113 113 / .22); color: #fca5a5; box-shadow: 0 0 0 1px rgb(248 113 113 / .6) inset, 0 8px 22px rgb(0 0 0 / .4); } +.ph-ghost.settle { transition: left .18s cubic-bezier(.2, .8, .2, 1), top .18s cubic-bezier(.2, .8, .2, 1); box-shadow: 0 0 0 1px rgb(56 189 248 / .35) inset; } +.ph-ghost.gone { opacity: 0; } +/* the remove zone a piece is dragged into */ +.ed-trash { position: absolute; left: 50%; bottom: 20px; z-index: 30; display: flex; align-items: center; gap: 10px; padding: 12px 18px; font-size: 13px; border-radius: 14px; + border: 1px dashed rgb(248 113 113 / .5); background: var(--pop-bg); color: #fca5a5; backdrop-filter: blur(10px); + transform: translate(-50%, 24px); opacity: 0; transition: transform .2s cubic-bezier(.2, .8, .2, 1), opacity .2s, background .15s, border-color .15s; pointer-events: none; } +.ed-trash.on { transform: translate(-50%, 0); opacity: 1; } +.ed-trash.hot { transform: translate(-50%, 0) scale(1.08); border-style: solid; border-color: #f87171; background: rgb(127 29 29 / .9); color: #fff; } +.ed-trash svg { transition: transform .2s; } +.ed-trash.hot svg { transform: rotate(-12deg) scale(1.15); } +/* a step that calls another script: the called name is fixed, the rest are its arguments */ +#ed-canvas .callw { display: inline-flex; vertical-align: top; align-items: center; gap: 4px; height: 22px; margin: 1px 1px 1px 0; padding: 0 7px 0 5px; border-radius: 5px; font-weight: 600; user-select: none; + background: rgb(167 139 250 / .16); color: var(--call-fg); box-shadow: 0 0 0 1px rgb(167 139 250 / .4) inset; } +#ed-canvas .callw svg { position: static; } +/* Drawflow turns text selection off for the whole canvas; Firefox then hides the caret in fields */ +#ed-canvas input, #ed-canvas textarea, #ed-canvas .cmd { user-select: text; -moz-user-select: text; } + +/* fields that wrap instead of scrolling sideways */ +#ed-canvas .fc > textarea { width: 100%; min-width: 0; min-height: 32px; padding: 6px 9px; font: inherit; font-size: 13px; line-height: 1.45; resize: none; overflow: hidden; + border-radius: 7px; border: 1px solid var(--node-line); background: var(--field-bg); color: var(--node-fg); } +#ed-canvas textarea:focus { outline: none; border-color: var(--c-focus); } + +/* problems sit on what needs a change; the reason shows on hover */ +#ed-canvas .fr.bad .fl { color: var(--c-bad); } +#ed-canvas .al-new.bad, #ed-canvas .cmd.bad { border-color: var(--c-bad) !important; box-shadow: 0 0 0 3px rgb(248 113 113 / .15); } +#ed-canvas .pr .missing { color: var(--c-bad); cursor: help; } +#ed-canvas .drawflow-node:has(> .drawflow_content_node > .nh.has-err) { border-color: var(--c-bad); box-shadow: 0 0 0 2px rgb(248 113 113 / .35), 0 6px 18px rgb(0 0 0 / .35); } +#ed-canvas .nh.has-err { cursor: help; } + +#ed-canvas .nh { display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 12px 0 6px; border-radius: 9px 9px 0 0; color: #fff; cursor: grab; user-select: none; } +#ed-canvas .cb-command .nh { background: var(--h-command); } +#ed-canvas .cb-run .nh { background: var(--h-run); } +#ed-canvas .cb-wait .nh { background: var(--h-wait); } +#ed-canvas .cb-arg .nh { background: var(--h-arg); } +#ed-canvas .cb-server .nh { background: var(--h-server); } +#ed-canvas .cb-alias .nh { background: var(--h-alias); } +#ed-canvas .cb-defaults .nh { background: var(--h-defaults); } +#ed-canvas .cb-runas .nh { background: var(--h-runas); } +#ed-canvas .cb-cooldown .nh { background: var(--h-cooldown); } +#ed-canvas .cb-rules .nh { background: var(--h-rules); } +/* Drawflow positions every svg absolutely (for its wires); header icons flow with the title */ +#ed-canvas .nh svg { position: static; flex-shrink: 0; } +#ed-canvas .grip { display: grid; place-items: center; width: 22px; height: 28px; border-radius: 5px; color: rgb(255 255 255 / .7); } +#ed-canvas .nh:hover .grip { color: #fff; } +#ed-canvas .nk { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#ed-canvas .nk b { font-weight: 600; } +#ed-canvas .offline .nh::after { content: "not connected"; margin-left: auto; padding: 2px 8px; font-size: 11px; border-radius: 999px; background: rgb(0 0 0 / .25); } + +/* port rows: header 38px, then rows of 26px from 6px below it; the port shapes line up with them */ +#ed-canvas .np { padding: 6px 0 4px; } +#ed-canvas .pr { display: flex; justify-content: space-between; gap: 24px; height: 26px; line-height: 26px; padding: 0 16px; font-size: 12.5px; color: var(--node-muted); } +#ed-canvas .drawflow-node .inputs, #ed-canvas .drawflow-node .outputs { position: absolute; top: 51px; width: 0; display: flex; flex-direction: column; gap: 10px; } +#ed-canvas .drawflow-node .inputs { left: 0; } +#ed-canvas .drawflow-node .outputs { right: 0; } +#ed-canvas .drawflow-node .input, #ed-canvas .drawflow-node .output { + width: 16px; height: 16px; margin: 0; top: -1px; left: -8px; right: auto; border: none; border-radius: 0; background: var(--c-flow); + cursor: crosshair; transition: transform .1s, opacity .1s, filter .1s; +} +#ed-canvas .drawflow-node .input { cursor: pointer; } +#ed-canvas .pt-flow { clip-path: polygon(10% 0, 100% 50%, 10% 100%); background: var(--c-flow) !important; color: var(--c-flow); } +#ed-canvas .pt-arg { border-radius: 50% !important; background: var(--c-arg) !important; color: var(--c-arg); } +#ed-canvas .pt-srv { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); background: var(--c-srv) !important; color: var(--c-srv); } +#ed-canvas .pt-call { border-radius: 3px !important; transform: scale(.85); background: var(--c-call) !important; color: var(--c-call); } +#ed-canvas .pt-alias { border-radius: 50% !important; background: var(--c-alias) !important; color: var(--c-alias); } +#ed-canvas .pt-def { border-radius: 3px !important; background: var(--c-def) !important; color: var(--c-def); } +#ed-canvas .pt-runas { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); background: var(--c-runas) !important; color: var(--c-runas); } +#ed-canvas .pt-cool { border-radius: 50% !important; background: var(--c-cool) !important; color: var(--c-cool); } +#ed-canvas .pt-rules { border-radius: 3px !important; background: var(--c-rules) !important; color: var(--c-rules); } +#ed-canvas .drawflow-node .input:hover, #ed-canvas .drawflow-node .output:hover { transform: scale(1.4); } +/* while wiring: the ports a wire can join grow and glow in their own color, the rest fades */ +#ed-canvas .drawflow-node .input.can { transform: scale(1.6); filter: drop-shadow(0 0 5px currentColor) drop-shadow(0 0 8px rgb(255 255 255 / .45)); } +#ed-canvas .drawflow-node .input.cannot { opacity: .12; } +#ed-canvas .drawflow-node.no-target { opacity: .4; } + +/* fields: always there, no "add setting" */ +#ed-canvas .sec { display: flex; flex-direction: column; gap: 8px; padding: 10px 14px 12px; border-top: 1px solid var(--node-line); } +#ed-canvas .fr { display: grid; grid-template-columns: 116px 1fr; align-items: center; gap: 10px; min-height: 30px; } +#ed-canvas .fl { font-size: 12.5px; color: var(--node-muted); } +#ed-canvas .fc { display: flex; align-items: center; min-width: 0; } +#ed-canvas .fc > input[type=text], #ed-canvas .fc > select, #ed-canvas .slashed { + width: 100%; min-width: 0; height: 32px; padding: 0 9px; font-size: 13px; border-radius: 7px; + border: 1px solid var(--node-line); background: var(--field-bg); color: var(--node-fg); cursor: text; +} +#ed-canvas .fc > select { cursor: pointer; } +#ed-canvas select option { background: var(--field-bg); color: var(--node-fg); } +#ed-canvas .slashed { display: flex; align-items: center; padding: 0 0 0 9px; } +#ed-canvas .slashed span { color: var(--node-muted); } +#ed-canvas .slashed input { flex: 1; min-width: 0; height: 100%; padding: 0 9px 0 2px; border: none; background: none; color: inherit; font-weight: 600; } +#ed-canvas input:focus, #ed-canvas select:focus, #ed-canvas .slashed:focus-within { outline: none; border-color: var(--c-focus); } +#ed-canvas .slashed input:focus { border: none; } +#ed-canvas .bad, #ed-canvas .slashed:has(.bad) { border-color: var(--c-bad) !important; box-shadow: 0 0 0 3px rgb(248 113 113 / .15); } + +/* iOS-style switches */ +#ed-canvas .sw { position: relative; display: inline-block; width: 38px; height: 22px; flex-shrink: 0; cursor: pointer; } +#ed-canvas .sw input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; } +#ed-canvas .sw span { position: absolute; inset: 0; border-radius: 999px; background: var(--switch-off); transition: background .2s; pointer-events: none; } +#ed-canvas .sw span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / .4); transition: transform .15s; } +#ed-canvas .sw input:checked + span { background: var(--c-ok); } +#ed-canvas .sw input:checked + span::after { transform: translateX(16px); } +#ed-canvas .sw input:focus-visible + span { box-shadow: 0 0 0 3px rgb(96 165 250 / .5); } + +/* ---------------- wires ---------------- */ +#ed-canvas .connection .main-path { stroke: var(--c-flow); stroke-width: 3px; opacity: .85; cursor: pointer; } +#ed-canvas .connection.t-arg .main-path { stroke: var(--c-arg); } +#ed-canvas .connection.t-srv .main-path { stroke: var(--c-srv); } +#ed-canvas .connection.t-call .main-path { stroke: var(--c-call); stroke-dasharray: 8 6; } +#ed-canvas .connection.t-alias .main-path { stroke: var(--c-alias); } +#ed-canvas .connection.t-def .main-path { stroke: var(--c-def); } +#ed-canvas .connection.t-runas .main-path { stroke: var(--c-runas); } +#ed-canvas .connection.t-cool .main-path { stroke: var(--c-cool); } +#ed-canvas .connection.t-rules .main-path { stroke: var(--c-rules); } +#ed-canvas .connection .main-path { pointer-events: none; } +#ed-canvas .connection .hit-path { fill: none; stroke: transparent; stroke-width: 16px; stroke-dasharray: none; pointer-events: stroke; cursor: pointer; } +#ed-canvas .connection:hover .main-path { opacity: 1; stroke-width: 6px; } +#ed-canvas .connection .main-path.selected { opacity: 1; stroke-width: 6px; stroke: #fca5a5 !important; } +#ed-canvas.ed-wiring .connection .hit-path { pointer-events: none; } +#ed-canvas[data-drag="arg"] svg.connection:last-of-type .main-path { stroke: var(--c-arg); } +#ed-canvas[data-drag="srv"] svg.connection:last-of-type .main-path { stroke: var(--c-srv); } +#ed-canvas[data-drag="call"] svg.connection:last-of-type .main-path { stroke: var(--c-call); stroke-dasharray: 8 6; } +#ed-canvas[data-drag="alias"] svg.connection:last-of-type .main-path { stroke: var(--c-alias); } +#ed-canvas[data-drag="def"] svg.connection:last-of-type .main-path { stroke: var(--c-def); } +#ed-canvas[data-drag="runas"] svg.connection:last-of-type .main-path { stroke: var(--c-runas); } +#ed-canvas[data-drag="cool"] svg.connection:last-of-type .main-path { stroke: var(--c-cool); } +#ed-canvas[data-drag="rules"] svg.connection:last-of-type .main-path { stroke: var(--c-rules); } +#ed-canvas .drawflow-delete { display: none; } + +/* ---------------- groups ---------------- */ +#ed-canvas .grp { position: absolute; z-index: 0; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--g) 70%, transparent); background: color-mix(in srgb, var(--g) 10%, transparent); } +#ed-canvas .grp-h { display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 10px 0 6px; border-radius: 11px 11px 0 0; background: color-mix(in srgb, var(--g) 35%, transparent); cursor: grab; } +#ed-canvas .grp-h .grip svg { position: static; } +#ed-canvas .grp-fill { flex: 1; align-self: stretch; } +#ed-canvas .grp-n { font-size: 12px; color: rgb(255 255 255 / .65); white-space: nowrap; } +#ed-canvas .grp-t { min-width: 0; max-width: 60%; height: 28px; padding: 0 6px; font-size: 15px; font-weight: 600; border: 1px solid transparent; border-radius: 6px; background: none; color: #fff; cursor: text; } +#ed-canvas .grp-t:focus { outline: none; border-color: rgb(255 255 255 / .5); background: rgb(0 0 0 / .2); } +#ed-canvas .grp-colors { display: flex; gap: 4px; } +#ed-canvas .grp-c { width: 14px; height: 14px; padding: 0; border-radius: 50%; border: 2px solid rgb(255 255 255 / .3); background: var(--c); cursor: pointer; } +#ed-canvas .grp .sw { margin-right: 2px; } +#ed-canvas .grp .sw input:disabled + span { opacity: .4; } +#ed-canvas .grp.off { border-style: dashed; background: color-mix(in srgb, var(--g) 4%, transparent); } +#ed-canvas .grp.off .grp-h { background: color-mix(in srgb, var(--g) 14%, transparent); } +#ed-canvas .grp.off .grp-t { color: rgb(255 255 255 / .55); } +#ed-canvas .grp-x { width: 26px; height: 26px; border: none; border-radius: 6px; background: none; color: #fff; font-size: 16px; cursor: pointer; } +#ed-canvas .grp-x:hover { background: rgb(0 0 0 / .3); } +#ed-canvas .grp-body { position: absolute; inset: 40px 0 0 0; } +#ed-canvas .grp.solo .grp-x { display: none; } +#ed-canvas .grp-sort { display: grid; place-items: center; width: 26px; height: 26px; border: none; border-radius: 6px; background: none; color: rgb(255 255 255 / .8); cursor: pointer; } +#ed-canvas .grp-sort:hover { color: #fff; background: rgb(0 0 0 / .3); } +#ed-canvas .grp-sort svg { position: static; } + +@media (max-width: 900px) { + .ed-bar { overflow-x: auto; } + .ed-btn { padding: 0 8px; } + .ed-yaml { position: absolute; inset: 0; width: auto; max-width: none; } + .ed-notes { max-width: calc(100% - 32px); bottom: 220px; } +} + +/* a step dragged onto a wire goes in between: the wire lights up, the node pops in */ +#ed-canvas .connection.insert-at .main-path { stroke: #60a5fa !important; stroke-width: 6px; opacity: 1; stroke-dasharray: 10 6; animation: wire-march .5s linear infinite; } +@keyframes wire-march { to { stroke-dashoffset: -16; } } +#ed-canvas .drawflow-node.inserting { box-shadow: 0 0 0 2px #60a5fa, 0 18px 40px rgb(0 0 0 / .55); } +#ed-canvas .drawflow-node.dropped-in { animation: node-in .35s cubic-bezier(.3, 1.5, .5, 1); } +@keyframes node-in { from { transform: scale(.94); } to { transform: scale(1); } } + +/* light mode: the canvas is light, nodes stay dark; group headers need dark text */ +html:not(.dark) #ed-canvas .grp-h { background: color-mix(in srgb, var(--g) 22%, white); } +html:not(.dark) #ed-canvas .grp-t { color: color-mix(in srgb, var(--g) 55%, black); } +html:not(.dark) #ed-canvas .grp-t:focus { border-color: color-mix(in srgb, var(--g) 50%, transparent); background: rgb(255 255 255 / .6); } +html:not(.dark) #ed-canvas .grp-n, html:not(.dark) #ed-canvas .grp-h .grip, html:not(.dark) #ed-canvas .grp-sort, html:not(.dark) #ed-canvas .grp-x { color: color-mix(in srgb, var(--g) 45%, #3f3f46); } +html:not(.dark) #ed-canvas .grp.off .grp-t { color: color-mix(in srgb, var(--g) 30%, #71717a); } +html:not(.dark) #ed-canvas .connection.t-flow .main-path { stroke: #71717a; } + +/* light mode: nodes, menus and the YAML panel turn light too; node headers keep their colors */ +html:not(.dark) .ed, html:not(.dark) .ph-ghost { + --node-bg: #ffffff; --node-fg: #18181b; --node-muted: #71717a; --node-line: #e4e4e7; --field-bg: #f4f4f5; + --pop-bg: rgb(255 255 255 / .97); --hover: rgb(0 0 0 / .05); --line-soft: rgb(0 0 0 / .08); + --piece-fg: #0369a1; --call-fg: #6d28d9; --code-bg: #ffffff; --code-side: #fafafa; --code-fg: #3f3f46; --switch-off: #d4d4d8; + --y-str: #15803d; --y-num: #b45309; --y-bool: #be185d; --y-punct: #a1a1aa; --y-com: #a1a1aa; +} +html:not(.dark) #ed-canvas .drawflow-node { box-shadow: 0 1px 2px rgb(0 0 0 / .06), 0 8px 24px rgb(0 0 0 / .08); } +html:not(.dark) .ed-pop, html:not(.dark) .ed-sub { box-shadow: 0 18px 40px rgb(0 0 0 / .14), 0 2px 6px rgb(0 0 0 / .06); } +html:not(.dark) .ed-tip { box-shadow: 0 10px 28px rgb(0 0 0 / .12); } +html:not(.dark) .ph-ghost { background: #e0f2fe; box-shadow: 0 0 0 1px rgb(14 165 233 / .4) inset, 0 8px 22px rgb(0 0 0 / .15); } +html:not(.dark) .ed-trash { color: #b91c1c; } +html:not(.dark) .ed-trash.hot { color: #fff; } +html:not(.dark) .ed-custom input { background: #f4f4f5; } +html:not(.dark) .ed-yaml-main header, html:not(.dark) .ed-files { border-color: rgb(0 0 0 / .08); } +html:not(.dark) .ed-file { color: #52525b; } +html:not(.dark) .ed-file.new { color: #15803d; } html:not(.dark) .ed-file.changed { color: #a16207; } html:not(.dark) .ed-file.deleted { color: #b91c1c; } + +/* motion: quick, springy, and only where something appears or moves */ +@keyframes ed-pop-in { from { opacity: 0; transform: scale(.96) translateY(-4px); } } +@keyframes ed-sub-in { from { opacity: 0; transform: translateX(-6px); } } +@keyframes ed-sub-in-flip { from { opacity: 0; transform: translateX(6px); } } +@keyframes ed-fade-in { from { opacity: 0; } } +@keyframes ed-rise { from { opacity: 0; transform: translateY(8px); } } +@keyframes ed-slide-in { from { opacity: .4; transform: translateX(24px); } } +@keyframes ed-born { from { opacity: 0; transform: scale(.94); } } +.ed-pop { transform-origin: 0 0; animation: ed-pop-in .18s cubic-bezier(.2, .9, .3, 1.15); } +.ed-mw.open > .ed-sub { animation: ed-sub-in .16s cubic-bezier(.2, .8, .2, 1); } +.ed-pop.flip .ed-mw.open > .ed-sub { animation-name: ed-sub-in-flip; } +.ed-tip { animation: ed-fade-in .14s .12s both; } +.ed-notes { animation: ed-rise .22s cubic-bezier(.2, .8, .2, 1); } +.ed-yaml { animation: ed-slide-in .24s cubic-bezier(.2, .8, .2, 1); } +.ed-wire-x { animation: ed-born .16s cubic-bezier(.3, 1.4, .5, 1); } +#ed-canvas .drawflow-node.born { animation: ed-born .26s cubic-bezier(.3, 1.3, .5, 1); } +.ed-tool, .ed-btn, .ed-save, .ed-mi, .ed-file, .ed-zoom button, #ed-canvas .pick, #ed-canvas .tag-x, #ed-canvas .grp-sort, #ed-canvas .grp-x { transition: background-color .14s, color .14s, border-color .14s, opacity .14s; } +.ed-tool:active:not(:disabled), .ed-btn:active:not(:disabled), .ed-save:active:not(:disabled) { transform: scale(.96); } +#ed-canvas .sw span::after { transition: transform .28s cubic-bezier(.3, 1.4, .5, 1); } +#ed-canvas .sw input:active + span::after { width: 22px; } +#ed-canvas .sw input:checked:active + span::after { transform: translateX(12px); } +#ed-canvas .grp { transition: background-color .2s, border-color .2s; } +#ed-canvas .drawflow-node.muted { transition: opacity .2s, filter .2s; } +@media (prefers-reduced-motion: reduce) { .ed *, .ed *::after, .ph-ghost { animation: none !important; transition: none !important; } } + +/* after Save: the command that writes the changes, to run on the proxy */ +.ed-confirm { position: absolute; left: 50%; top: 16px; z-index: 25; display: flex; align-items: center; gap: 12px; padding: 8px 8px 8px 16px; transform: translateX(-50%); + border-radius: 12px; border: 1px solid var(--line-soft); background: var(--pop-bg); color: var(--node-fg); box-shadow: 0 18px 40px rgb(0 0 0 / .35); backdrop-filter: blur(12px); + animation: ed-drop-in .24s cubic-bezier(.2, .9, .3, 1.15); white-space: nowrap; } +@keyframes ed-drop-in { from { opacity: 0; transform: translate(-50%, -10px); } } +.ed-confirm-t { font-size: 13px; color: var(--node-muted); } +.ed-confirm code { padding: 5px 10px; border-radius: 7px; font: 600 13px ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--field-bg); color: var(--node-fg); user-select: all; } +.ed-confirm .ed-btn { height: 30px; } +.ed-confirm-x { display: grid; place-items: center; width: 30px; height: 30px; border: none; border-radius: 7px; background: none; color: var(--node-muted); cursor: pointer; } +.ed-confirm-x:hover { background: var(--hover); color: var(--node-fg); } diff --git a/src/lib/editor/editor.js b/src/lib/editor/editor.js new file mode 100644 index 0000000..81597a9 --- /dev/null +++ b/src/lib/editor/editor.js @@ -0,0 +1,2841 @@ +// CommandBridge script editor: every script as a node graph, live on the proxy through the relay. +// The YAML side lives in script-yaml.js; positions and groups go to the proxy's editor-layout.json. + +const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +const splitList = (v) => String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean); +const isZero = (v) => v != null && /^\s*0+(\.0+)?\s*(ms|s|m|h)?\s*$/i.test(String(v)); +const CONFIRM_CODE = /^[abcdefghjkmnpqrstuvwxyz23456789]{6}$/; + +// the same rules the proxy validates with, checked while typing +const RULES = { + name: [/^[a-z][a-z0-9-]{2,32}$/, "3 to 33 lowercase letters, digits or -, starting with a letter"], + argname: [/^[A-Za-z][A-Za-z0-9_-]*$/, "letters, digits, _ or -, starting with a letter"], + duration: [/^\s*\d+(\.\d+)?\s*(ms|s|m|h)?\s*$/i, "a duration like 500ms, 5s, 1m or 1h"], + // Aliases may be shorter than script names, e.g. /ft. + alias: [/^[a-z][a-z0-9-]{0,32}$/, "lowercase letters, digits or -, starting with a letter"], + suggestions: [/^\s*([a-z0-9._+\-]+\s*(,\s*[a-z0-9._+\-]+\s*)*)?$/, "words of lowercase letters, digits and . _ + -, separated by commas"], + serverid: [/^\S+$/, "a server id without spaces"], +}; +const PLAYER_TYPES = ["STRING", "PLAYERS", "OFFLINE_PLAYER"]; // what CB can resolve to a player +const RUN_AS = ["CONSOLE", "PLAYER", "OPERATOR"]; +const PLACEHOLDER = /\$\{([^}]*)\}/g; + +// Ports per node type: [label, type]. A wire only joins ports of the same type, shape and color. +// Only text and switches live inside nodes; everything else is a node of its own, wired in. +const PORTS = { + command: { in: [["aliases", "alias"], ["arguments", "arg"], ["registered on", "srv"], ["defaults", "def"], ["called by", "call"]], out: [["then", "flow"]] }, + run: { in: [["after", "flow"], ["targets", "srv"], ["run as", "runas"], ["cooldown", "cool"], ["server rules", "rules"]], out: [["then", "flow"], ["calls", "call"]] }, + wait: { in: [["after", "flow"]], out: [["then", "flow"]] }, + arg: { in: [], out: [["argument", "arg"]] }, + server: { in: [], out: [["server", "srv"]] }, + alias: { in: [], out: [["aliases", "alias"]] }, + defaults: { in: [["targets", "srv"]], out: [["defaults", "def"]] }, + runas: { in: [], out: [["run as", "runas"]] }, + cooldown: { in: [], out: [["cooldown", "cool"]] }, + rules: { in: [], out: [["server rules", "rules"]] }, +}; +// port classes by name, so the code says what it wires +const P = { + cmd: { aliases: "input_1", args: "input_2", reg: "input_3", defaults: "input_4", calledBy: "input_5", then: "output_1" }, + run: { after: "input_1", targets: "input_2", runas: "input_3", cooldown: "input_4", rules: "input_5", then: "output_1", calls: "output_2" }, + def: { targets: "input_1" }, +}; +const REQUIRED_PORTS = { command: ["registered on", "defaults", "then"] }; +const TITLES = { command: "Command", run: "Execute", wait: "Wait", arg: "Argument", server: "Server", alias: "Aliases", defaults: "Defaults", runas: "Run as", cooldown: "Cooldown", rules: "Server rules" }; +const svg = (inner, size = 15) => ``; +const ICONS = { + command: svg(''), + run: svg(''), + wait: svg(''), + arg: svg(''), + server: svg(''), + alias: svg(''), + defaults: svg(''), + runas: svg(''), + cooldown: svg(''), + rules: svg(''), +}; +const GRIP = ``; +// a proxy and a backend look different wherever servers are listed +const SRV_ICONS = { + BACKEND: svg(''), + VELOCITY: svg(''), +}; +const RUN_AS_ICONS = { + CONSOLE: svg(''), + PLAYER: svg(''), + OPERATOR: svg(''), +}; +// Lucide icons used beyond the node icons (lucide.dev, ISC licence) +const LUCIDE = { + "type": '', + "text": '', + "wrap-text": '', + "hash": '', + "sigma": '', + "move-horizontal": '', + "toggle-left": '', + "clock-3": '', + "users": '', + "user-round-x": '', + "paw-print": '', + "shapes": '', + "globe": '', + "map-pin": '', + "map": '', + "compass": '', + "rotate-3d": '', + "trees": '', + "package": '', + "sparkles": '', + "flask-conical": '', + "volume-2": '', + "server": '', + "power-off": '', + "trash-2": '', + "file": '', + "folder-open": '' +}; +const TYPE_ICONS = { STRING: "type", TEXT: "text", GREEDY_STRING: "wrap-text", INTEGER: "hash", DOUBLE: "sigma", RANGE: "move-horizontal", BOOLEAN: "toggle-left", + TIME: "clock-3", PLAYERS: "users", OFFLINE_PLAYER: "user-round-x", ENTITIES: "paw-print", ENTITY_TYPE: "shapes", WORLD: "globe", LOCATION: "map-pin", + LOCATION_2D: "map", ANGLE: "compass", ROTATION: "rotate-3d", BIOME: "trees", ITEM_STACK: "package", ENCHANTMENT: "sparkles", POTION_EFFECT: "flask-conical", + SOUND: "volume-2", SERVER: "server" }; +const typeIcon = (t) => svg(LUCIDE[TYPE_ICONS[t]] || LUCIDE.type); +// what a script file may contain, for linting and completing the YAML panel (mirrors the proxy's Script records) +const Y = { + bool: { type: "bool" }, str: { type: "str" }, dur: { type: "dur" }, int: { type: "int" }, server: { type: "server" }, + enum: (values) => ({ type: "enum", values }), + map: (keys, required = []) => ({ type: "map", keys, required }), + list: (of) => ({ type: "list", of }), +}; +const Y_TARGETS = Y.list(Y.map({ id: Y.server, location: Y.enum(["VELOCITY", "BACKEND"]) }, ["id", "location"])); +const Y_RULES = Y.map({ "target-required": Y.bool, "schedule-online": Y.bool, "player-arg": { type: "arg" } }); +const Y_SCRIPT = Y.map({ + version: Y.int, name: { type: "name" }, enabled: Y.bool, description: Y.str, aliases: Y.list(Y.str), + permissions: Y.map({ enabled: Y.bool, silent: Y.bool }), + register: Y_TARGETS, + defaults: Y.map({ "run-as": Y.enum(RUN_AS), execute: Y_TARGETS, server: Y_RULES, delay: Y.dur, cooldown: Y.dur }), + args: Y.list(Y.map({ name: Y.str, required: Y.bool, type: { type: "enum", values: null }, suggestions: Y.list(Y.str) }, ["name"])), + commands: Y.list(Y.map({ command: Y.str, "run-as": Y.enum(RUN_AS), execute: Y_TARGETS, server: Y_RULES, delay: Y.dur, cooldown: Y.dur }, ["command"])), +}, ["version", "name", "permissions", "register", "defaults", "args", "commands"]); +const Y_HINTS = { bool: "true / false", enum: "one of", dur: "duration", int: "number", str: "text", server: "server id", arg: "argument", name: "name", map: "section", list: "list" }; + +// argument types by family, for the type menu +const ARG_FAMILIES = [ + ["Text", ["STRING", "TEXT", "GREEDY_STRING"]], + ["Numbers", ["INTEGER", "DOUBLE", "RANGE", "BOOLEAN", "TIME"]], + ["Players and entities", ["PLAYERS", "OFFLINE_PLAYER", "ENTITIES", "ENTITY_TYPE"]], + ["World", ["WORLD", "LOCATION", "LOCATION_2D", "ANGLE", "ROTATION", "BIOME"]], + ["Items and effects", ["ITEM_STACK", "ENCHANTMENT", "POTION_EFFECT", "SOUND"]], + ["Network", ["SERVER"]], +]; +// Bukkit uses the Spigot logo. +const logo = (file) => ``; +Object.assign(SRV_ICONS, { + VELOCITY: logo("velocity.svg"), PAPER: logo("paper.svg"), FOLIA: logo("folia.svg"), BUKKIT: logo("spigot.svg"), FORGE: logo("forge.svg"), FABRIC: logo("fabric.png"), +}); +const srvIcon = (loc, platform) => SRV_ICONS[platform] || SRV_ICONS[loc]; +const srvKind = (loc, platform) => (SRV_ICONS[platform] && platform !== "VELOCITY" ? platform.toLowerCase() : loc === "VELOCITY" ? "proxy" : "backend"); +const SRV_COLORS = { BACKEND: "#f59e0b", VELOCITY: "#60a5fa" }; +const MINIMAP_COLORS = { command: "#8b5cf6", run: "#22c55e", wait: "#64748b", arg: "#38bdf8", server: "#f59e0b", alias: "#ec4899", defaults: "#14b8a6", runas: "#eab308", cooldown: "#94a3b8", rules: "#f97316" }; +const GROUP_COLORS = ["#8b5cf6", "#22c55e", "#38bdf8", "#f59e0b", "#ef4444", "#94a3b8"]; +const validGroup = (g) => g && typeof g.id === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(g.id) && typeof g.title === "string" && /^#[0-9a-f]{6}$/i.test(g.color) + && Array.isArray(g.members) && g.members.every((m) => typeof m === "string") && [g.x, g.y, g.w, g.h].every(Number.isFinite); +const kindOf = (node) => node.class.replace("cb-", ""); +const portType = (kind, side, port) => PORTS[kind][side][Number(port.split("_")[1]) - 1]?.[1]; +const portLabel = (kind, side, port) => PORTS[kind][side][Number(port.split("_")[1]) - 1]?.[0]; + +/* ---------------- fields inside nodes: text and switches only ---------------- */ + +const row = (label, control, req = false) => `
${label}${req ? '*' : ""}
${control}
`; +const text = (f, v, ph, rule = "", extra = "") => ``; +const toggle = (f, on, label) => ``; +const duration = (f, v, ph, req = false) => text(f, v, ph, "duration", req ? ' data-req="1"' : ""); +let segId = 0; +const seg = (f, options, current) => { const name = "seg" + ++segId; + return `
${options.map(([v, label]) => ``).join("")}
`; }; +const RUN_AS_SEG = RUN_AS.map((r) => [r, r.toLowerCase()]); +// a dropdown in the editor's own menu style; the value lives in data-v +const CHEVRON = svg('', 14); +const pick = (f, kind, v, label, icon) => ``; +// whose server target-required and schedule-online look at: the sender, or a player given as an argument +const playerPick = (f, v) => pick(f, "player", v, v || "who ran it", v ? svg(LUCIDE.users) : RUN_AS_ICONS.PLAYER); +const PLAYER_TIP = "Target required and schedule online look at this player's server. Normally that's the player who ran the command; pick an argument to use the player it names instead."; +// one alias: a row of the list, edited in place +const tag = (a) => `
/
`; +// a placeholder inside the command text: one piece, dragged to move it or out to remove it +const ph = (name) => `${esc(name)}`; +const textHtml = (t) => String(t ?? "").split(/(\$\{[^}]*\})/).map((part) => (/^\$\{[^}]*\}$/.test(part) ? ph(part.slice(2, -1)) : esc(part))).join(""); +// a step that calls another script shows the called name as a fixed piece; the rest are its arguments +const CALL_ICON = svg('', 13); +const cmdHtml = (t, calling = false) => { + const m = calling && String(t ?? "").match(/^(\S+)(.*)$/s); + return m ? `${CALL_ICON}/${esc(m[1].replace(/^\//, ""))}${textHtml(m[2])}` : textHtml(t); +}; + +function nodeBody(kind, v) { + if (kind === "command") { + return `
+ ${row("name", `
/${text("name", v.name, "my-command", "name", ' data-req="1"')}
`, true)} + ${row("description", ``)} + ${row("needs permission", toggle("perm", v.perm !== "false", "needs permission"))} + ${row("silent denial", toggle("silent", v.silent === "true", "silent denial"))} +
`; + } + if (kind === "run") { + return `
${cmdHtml(v.command)}
`; + } + if (kind === "wait") return `
${row("for", duration("delay", v.delay ?? "1s", "1s", true), true)}
`; + if (kind === "arg") { + return `
+ ${row("name", text("name", v.name, "player", "argname", ' data-req="1"'), true)} + ${row("type", pick("type", "type", v.type || "STRING", v.type || "STRING", typeIcon(v.type || "STRING")))} + ${row("required", toggle("required", v.required === "true", "required"))} + ${row("suggestions", text("suggestions", v.suggestions, "steve, alex", "suggestions"))} +
`; + } + if (kind === "server") { + // fixed once added: a different server is a different node, picked from the menu + return `
`; + } + if (kind === "alias") { + // a list of names, then a field like the command's name to add one more + const list = splitList(v.aliases); + return `
${list.map(tag).join("")}
+
`; + } + if (kind === "defaults") { + return `
+ ${row("run as", seg("d-runas", RUN_AS_SEG, v["d-runas"] || "CONSOLE"))} + ${row("delay", duration("d-delay", v["d-delay"], "none"))} + ${row("cooldown", duration("d-cooldown", v["d-cooldown"], "none"))} + ${row("target required", toggle("d-target", v["d-target"] === "true", "target required"))} + ${row("schedule online", toggle("d-online", v["d-online"] === "true", "schedule online"))} + ${row(`checks player`, playerPick("d-player", v["d-player"]))} +
`; + } + if (kind === "runas") return `
${seg("value", RUN_AS_SEG, v.value || "CONSOLE")}
`; + if (kind === "cooldown") return `
${row("for", duration("cooldown", v.cooldown, "10s", true), true)}
`; + return `
+ ${row("target required", toggle("r-target", v["r-target"] === "true", "target required"))} + ${row("schedule online", toggle("r-online", v["r-online"] === "true", "schedule online"))} + ${row(`checks player`, playerPick("r-player", v["r-player"]))} +
`; +} + +const srvLoc = (v) => (v.location === "VELOCITY" ? "VELOCITY" : "BACKEND"); + +function nodeHtml(kind, v) { + const ports = PORTS[kind]; + let rows = ""; + const req = REQUIRED_PORTS[kind] || []; + for (let i = 0; i < Math.max(ports.in.length, ports.out.length); i++) { + const cell = (p) => (p ? `${p[0]}${req.includes(p[0]) ? '*' : ""}` : ""); + const left = kind === "server" ? `${srvLoc(v) === "VELOCITY" ? "proxy" : "backend"}` : cell(ports.in[i]); + rows += `
${left}${cell(ports.out[i])}
`; + } + const title = kind === "command" ? `/${esc(v.name || "")}` + : kind === "server" ? `${esc(v.id)}` : `${TITLES[kind]}`; + const enabled = kind === "command" ? `${toggle("enabled", v.enabled !== "false", "enabled")}` : ""; + const icon = kind === "server" ? SRV_ICONS[srvLoc(v)] : ICONS[kind]; + return `
${GRIP}${icon}${title}${enabled}
${rows ? `
${rows}
` : ""}${nodeBody(kind, v)}`; +} + +function cbEditor() { + // kept out of Alpine's reactive data: proxies around Drawflow and ES modules break them + let df, YAML, conv, building = false, syncingCalls = false, restoring = false; + const meta = new Map(); // node id -> { file, version } for commands, { src } for args and steps loaded from a file + let owner = new Map(); // node id -> the first command node that uses it, recomputed on every refresh + let owners = new Map(); // node id -> every command node that uses it + let groups = []; // { id, title, color, x, y, w, h, members: [script ids, see sidOf], auto } in canvas coordinates + let layout = { nodes: {}, groups: [] }; + const LAYOUT_VERSION = 3; // older layouts used other node types or one Server node per id; their nodes are laid out fresh + const selected = new Set(); + const history = { past: [], future: [], last: null, timer: null, pending: false }; + let layoutTimer = null, lastLayout = ""; + let wiring = null; // { from, port, x, y, made } while a wire is dragged + let wireShown = null; // the wire the delete button belongs to + const aim = { p0: null, p1: null, t: 0, timer: null }; // pointer trail for submenus' safe triangle + let clipboard = null; // { nodes, edges } copied with Ctrl+C + const bases = {}; // file -> source YAML whose list indexes the graph's metadata refers to + let unread = new Set(); // files skipped by the current snapshot must not become deletions + let latestScripts = null; // external changes wait for Revert while the graph has edits + const applied = new Map(); // successful writes awaiting their state snapshot + let staged = null; // the change set waiting on the proxy for /cb editor confirm, as JSON + let draftTimer = null, lastDraft = ""; + let yamlTimer = null; + let lint = { text: null }, yamlCellSize = null; + let insertAt = null; // { from, to, out, inp, wire } a dragged step would be dropped into + let mouse = null; // last pointer position over the canvas, where Ctrl+V pastes + + return { + status: "connecting", + closeReason: "", + scripts: {}, + servers: {}, + loadedFiles: [], + out: {}, + deletions: [], + problems: [], + results: [], + showYaml: false, + showWarnings: false, + showMinimap: true, + selectedFile: null, + selectionSize: 0, + zoom: 100, + canUndo: false, + canRedo: false, + busy: false, + tip: null, // { text, x, y, right, err } for the hover tooltip + yamlPick: null, // the file chosen in the YAML panel + confirmCode: null, // the code to run /cb editor confirm with, once Save staged the changes + copied: false, + yamlDraft: null, // { file, text } being typed in the YAML panel + yamlError: "", + externalChanges: false, + yamlSugg: null, // { items, sel, x, y, from, to } completion popup at the caret + yamlLine: -1, // the caret's line, for the message of a problem on it + pop: null, // { x, y, title, items: [{ label, hint, kind, keys, run, sub }] } for port and context menus + tool: "select", // left-drag on the canvas: "select" draws a selection box, "grab" pans + wireX: null, // { x, y } of the delete button on a selected wire + relay: null, + deferred: false, + removingNodes: false, + dragId: 0, + + async init() { + const relay = window.cbRelay(window.cbLink()); + if (!relay) { this.status = "nolink"; return; } + [YAML, conv] = await Promise.all([import("/js/yaml/index.js"), import("/lib/editor/script-yaml.js")]); + window.cbArgTypes = conv.ARG_TYPES; + this.setupCanvas(); + relay.onOpen = () => this.send({ type: "hello" }); + relay.onMessage = (msg) => this.receive(msg); + relay.onClose = (reason) => { this.status = "closed"; this.closeReason = reason; }; + this.relay = relay.connect(); + window.addEventListener("beforeunload", (e) => { if (this.changes()) e.preventDefault(); }); + }, + + statusText() { + return { connecting: "Connecting…", closed: "Session ended: " + this.closeReason }[this.status] ?? ""; + }, + + /* ================= canvas ================= */ + + setupCanvas() { + const el = document.getElementById("ed-canvas"); + df = new Drawflow(el); + df.reroute = false; + df.draggable_inputs = false; + df.zoom_enter = () => {}; // wheel zoom is ours, centred on the cursor + df.key = () => {}; // keys are ours too: Drawflow's Delete removes whatever it last saw clicked, even while typing + df.start(); + + df.on("connectionCreated", (c) => this.onConnect(c)); + df.on("connectionRemoved", (c) => this.onDisconnect(c)); + df.on("nodeRemoved", (id) => this.onNodeRemoved(id)); + // Drawflow stores a dragged node's position one mouse step behind where it draws it; trust the drawing + df.on("nodeMoved", (id) => { + if (insertAt) this.insertInto(String(id)); + const n = df.drawflow.drawflow.Home.data[id], node = document.getElementById("node-" + id); + if (n && node) { n.pos_x = parseFloat(node.style.left); n.pos_y = parseFloat(node.style.top); } + this.refresh(); + }); + df.on("translate", () => this.drawMinimap()); + // keeps each wire's click area on the wire while Drawflow redraws it + new MutationObserver((list) => { + for (const m of list) if (m.target.classList?.contains("main-path")) m.target.parentElement.querySelector(".hit-path")?.setAttribute("d", m.target.getAttribute("d")); + }).observe(df.precanvas, { subtree: true, attributes: true, attributeFilter: ["d"] }); + + el.addEventListener("wheel", (e) => { + e.preventDefault(); + const r = el.getBoundingClientRect(); + this.zoomAt(df.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), e.clientX - r.left, e.clientY - r.top); + }, { passive: false }); + + el.addEventListener("mousedown", (e) => this.onPointerDown(e), true); + // right-drag pans; keep Drawflow's right-click menu out of the way + el.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); }, true); + el.addEventListener("click", (e) => this.onClick(e)); + el.addEventListener("mousemove", (e) => { mouse = { x: e.clientX, y: e.clientY }; }); + el.addEventListener("mouseleave", () => { mouse = null; }); + el.addEventListener("input", (e) => this.onInput(e)); + el.addEventListener("change", (e) => this.onChange(e)); + el.addEventListener("keydown", (e) => this.onFieldKey(e)); + el.addEventListener("focusin", (e) => { if (e.target.dataset?.rule === "duration") this.durationMenu(e.target); }); + el.addEventListener("focusout", (e) => { + if (e.target === this.pop?.anchor && !e.relatedTarget?.closest?.(".ed-pop")) setTimeout(() => { if (this.pop?.anchor === e.target && document.activeElement !== e.target) this.pop = null; }, 150); + if (e.target.classList.contains("tag-in")) this.commitTag(e.target); + // an alias row emptied out goes away + if (e.target.classList.contains("al-in") && !e.target.value.trim()) { const box = e.target.closest(".sec"); e.target.closest(".tag").remove(); this.saveTags(box); } + if (e.target.classList.contains("cmd")) this.syncCalls(); // the first word may now name another script + }); + // nothing in the editor is dragged natively; Firefox would try to open a dropped piece as a link + el.addEventListener("dragstart", (e) => e.preventDefault()); + // only plain text is pasted into a command + el.addEventListener("paste", (e) => { + if (!e.target.closest?.(".cmd")) return; + e.preventDefault(); + document.execCommand("insertText", false, e.clipboardData.getData("text/plain").replace(/\s*\n\s*/g, " ")); + }); + // tooltips for fields with a problem, and for the save button + document.addEventListener("mouseover", (e) => this.showTip(e.target, e)); + document.addEventListener("mousedown", () => { this.tip = null; }, true); + document.addEventListener("keydown", (e) => this.onKey(e), true); + for (const type of ["copy", "cut", "paste"]) document.addEventListener(type, (e) => this.onClipboard(e)); + // Firefox drops keys like Ctrl+A and Ctrl+V while a plain element has focus; a hidden text field gets them all. + // Clicking anything in the editor that isn't a field hands focus back to it. + const keyBox = document.querySelector(".ed-keys"); + keyBox.addEventListener("beforeinput", (e) => e.preventDefault()); + // decided by what was clicked: Firefox may not have focused a clicked field yet when mouseup arrives. + // Click handlers that focus a field themselves run after this. + let down = null; + document.addEventListener("mousedown", (e) => { down = e.target; }, true); + document.addEventListener("mouseup", (e) => { + // text being selected (in the YAML panel) keeps its selection + if (this.status !== "online" || !e.target.closest?.(".ed") || e.target.closest(".ed-yaml") || !getSelection().isCollapsed) return; + if (!this.isTyping(down) && !this.isTyping(e.target)) keyBox.focus({ preventScroll: true }); + }, true); + document.addEventListener("keyup", (e) => { if (e.code === "Space") el.classList.remove("ed-space"); }); + window.addEventListener("resize", () => this.drawMinimap()); + }, + + toWorld(sx, sy) { + const r = df.container.getBoundingClientRect(); + const ox = df.precanvas.clientWidth / 2, oy = df.precanvas.clientHeight / 2; + return { x: (sx - r.left - df.canvas_x - ox) / df.zoom + ox, y: (sy - r.top - df.canvas_y - oy) / df.zoom + oy }; + }, + + // screen position inside the work area, for popups + toWork(sx, sy) { + const r = df.container.parentElement.getBoundingClientRect(); + return { x: sx - r.left, y: sy - r.top }; + }, + + /* ---------- pointer: pan, select, move, groups, wires, port menus ---------- */ + + onPointerDown(e) { + const el = df.container; + const t = e.target; + const nodeEl = t.closest(".drawflow-node"); + if (!t.closest(".ed-pop") && t !== this.pop?.anchor) this.pop = null; + if (!t.closest(".connection")) this.wireX = null; + const empty = t === el || t === df.precanvas || t.classList.contains("grp-body"); + + if (e.button === 2) { + // right-drag pans, a right-click without moving opens the context menu + e.stopPropagation(); e.preventDefault(); + const x0 = e.clientX, y0 = e.clientY; + let moved = false; + return this.drag(e, "ed-panning", (dx, dy, ev) => { + moved ||= Math.hypot(ev.clientX - x0, ev.clientY - y0) > 4; + if (moved) this.setView(df.zoom, df.canvas_x + dx, df.canvas_y + dy); + }, () => { if (!moved) this.contextMenu(t, x0, y0); }); + } + if (e.button === 1 || el.classList.contains("ed-space") || (this.tool === "grab" && empty)) { + e.stopPropagation(); e.preventDefault(); + return this.drag(e, "ed-panning", (dx, dy) => this.setView(df.zoom, df.canvas_x + dx, df.canvas_y + dy)); + } + + if (t.closest(".connection")) { + // a wire: select it and offer a delete button right there (Delete works too) + e.stopPropagation(); + return this.selectWire(t, e.clientX, e.clientY); + } + + const port = t.classList.contains("output") || t.classList.contains("input") ? t : null; + if (port && e.altKey) { + // Alt-click breaks every wire on a port, like in Unreal + e.stopPropagation(); e.preventDefault(); + const id = nodeEl.id.slice(5), cls = [...port.classList].find((c) => /^(in|out)put_\d+$/.test(c)); + const n = df.getNodeFromId(id), side = cls.startsWith("in") ? "inputs" : "outputs"; + for (const c of [...n[side][cls].connections]) { + side === "outputs" ? df.removeSingleConnection(id, c.node, cls, c.output) : df.removeSingleConnection(c.node, id, c.input, cls); + } + return; + } + if (port && t.classList.contains("input")) { + e.stopPropagation(); e.preventDefault(); + const x0 = e.clientX, y0 = e.clientY; + window.addEventListener("mouseup", (u) => { if (Math.hypot(u.clientX - x0, u.clientY - y0) < 5) this.portMenu(nodeEl.id.slice(5), "in", [...t.classList].find((c) => c.startsWith("input_")), u.clientX, u.clientY); }, { once: true }); + return; + } + if (port) return this.startWire(e, nodeEl, t); + + const grpHead = t.closest(".grp-h"); + if (grpHead && !t.closest("input, button, label")) { + e.stopPropagation(); e.preventDefault(); + const g = this.groupById(grpHead.parentElement.dataset.gid); + const inside = this.nodesIn(g); + return this.drag(e, "ed-moving", (dx, dy) => { + g.x += dx / df.zoom; g.y += dy / df.zoom; + for (const id of inside) this.moveNodeBy(id, dx / df.zoom, dy / df.zoom); + this.renderGroups(); + }, () => this.refresh()); + } + + if (nodeEl) { + const id = nodeEl.id.slice(5); + // an argument pill, or a piece already in a command, is dragged into place with a live preview + const piece = t.closest(".pill, .ph"); + if (piece) { this.pieceDrag(e, piece); return; } + const control = t.closest("input, select, button, textarea, label, .cmd, .args, .al-list"); + // the command box places its own caret; Drawflow must not move the node + if (t.closest(".cmd, .args")) e.stopPropagation(); + if ((e.shiftKey || e.ctrlKey || e.metaKey) && !control) { + e.stopPropagation(); + selected.has(id) ? selected.delete(id) : selected.add(id); + return this.paintSelection(); + } + if (!selected.has(id)) { selected.clear(); selected.add(id); this.paintSelection(); } + if (control) return; + // leaving a field for the node itself, so shortcuts act on the graph again + document.querySelector(".ed-keys").focus({ preventScroll: true }); + if (!t.closest(".nh")) { e.stopPropagation(); return; } // only the header moves a node + if (selected.size > 1) { + // move the whole selection; Drawflow itself only moves one node + e.stopPropagation(); e.preventDefault(); + return this.drag(e, "ed-moving", (dx, dy) => { + for (const s of selected) this.moveNodeBy(s, dx / df.zoom, dy / df.zoom); + }, () => this.refresh()); + } + el.classList.add("ed-moving"); + // a loose Execute or Wait dragged onto a step-to-step wire goes in between when dropped + const n0 = df.getNodeFromId(id); + const loose = ["run", "wait"].includes(kindOf(n0)) && !n0.inputs.input_1.connections.length && !n0.outputs.output_1.connections.length; + const watch = () => this.findInsert(id); + if (loose) window.addEventListener("mousemove", watch); + window.addEventListener("mouseup", () => { el.classList.remove("ed-moving"); window.removeEventListener("mousemove", watch); }, { once: true }); + return; + } + + if (empty && e.button === 0) { + // selection box; clicking the canvas also leaves any field, so shortcuts like Ctrl+Z act on the graph + e.stopPropagation(); e.preventDefault(); + document.querySelector(".ed-keys").focus({ preventScroll: true }); + if (!e.shiftKey) { selected.clear(); this.paintSelection(); } + const r = el.getBoundingClientRect(); + const box = document.createElement("div"); + box.className = "ed-marquee"; + el.appendChild(box); + const x0 = e.clientX, y0 = e.clientY; + let x1 = x0, y1 = y0; + return this.drag(e, "ed-selecting", (dx, dy, ev) => { + x1 = ev.clientX; y1 = ev.clientY; + Object.assign(box.style, { left: Math.min(x0, x1) - r.left + "px", top: Math.min(y0, y1) - r.top + "px", width: Math.abs(x1 - x0) + "px", height: Math.abs(y1 - y0) + "px" }); + }, () => { + box.remove(); + const [l, tp, rt, bt] = [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)]; + if (rt - l > 3 || bt - tp > 3) { + for (const n of el.querySelectorAll(".drawflow-node")) { + const b = n.getBoundingClientRect(); + if (b.right > l && b.left < rt && b.bottom > tp && b.top < bt) selected.add(n.id.slice(5)); + } + } + this.paintSelection(); + }); + } + }, + + selectWire(t, sx, sy) { + selected.clear(); this.paintSelection(); + const main = t.closest(".connection").querySelector(".main-path"); + df.connection_selected?.classList.remove("selected"); + df.connection_selected = main; + main.classList.add("selected"); + wireShown = main; + const p = this.toWork(sx, sy); + this.wireX = { x: p.x, y: p.y }; + }, + + // a wire starts: highlight the ports it may join in their own color, mute everything else + startWire(e, nodeEl, portEl) { + const el = df.container; + const from = df.getNodeFromId(nodeEl.id.slice(5)); + const port = [...portEl.classList].find((c) => c.startsWith("output_")); + const id = ++this.dragId; + wiring = { from: String(from.id), port, x: e.clientX, y: e.clientY, made: false }; + el.classList.add("ed-wiring"); + el.dataset.drag = portType(kindOf(from), "out", port); + el.querySelectorAll(".drawflow-node").forEach((node) => { + const to = df.getNodeFromId(node.id.slice(5)); + let any = false; + node.querySelectorAll(".input").forEach((inp) => { + const ok = this.allowed(from, port, to, [...inp.classList].find((c) => c.startsWith("input_")), true); + inp.classList.add(ok ? "can" : "cannot"); + any ||= ok; + }); + if (!any && node !== nodeEl) node.classList.add("no-target"); + }); + window.addEventListener("mouseup", (u) => setTimeout(() => { + if (id !== this.dragId) return; // a later drag already started + el.classList.remove("ed-wiring"); + delete el.dataset.drag; + el.querySelectorAll(".can, .cannot, .no-target").forEach((n) => n.classList.remove("can", "cannot", "no-target")); + const w = wiring; + wiring = null; + if (!w || w.made) return; + // a click on a port, or a wire dropped on empty space, opens the menu of what can go there + const onEmpty = u.target === el || u.target === df.precanvas || u.target.classList?.contains("grp-body"); + const clicked = Math.hypot(u.clientX - w.x, u.clientY - w.y) < 5; + if (clicked || onEmpty) this.portMenu(w.from, "out", w.port, u.clientX, u.clientY, !clicked); + }), { once: true }); + }, + + // the step-to-step wire running under a dragged node, lit up as the place it will go + findInsert(id) { + const node = document.getElementById("node-" + id), r = node.getBoundingClientRect(), from = df.getNodeFromId(id); + let hit = null; + for (const c of df.container.querySelectorAll("svg.connection.t-flow")) { + const path = c.querySelector(".main-path"), len = path.getTotalLength(), m = path.getScreenCTM(); + const cls = [...c.classList]; + const a = cls.find((k) => k.startsWith("node_out_"))?.slice(14), b = cls.find((k) => k.startsWith("node_in_"))?.slice(13); + if (!a || !b || a === id || b === id) continue; + for (let t = 0.1; t < 0.9 && !hit; t += 0.05) { + const p = path.getPointAtLength(len * t), x = p.x * m.a + m.e, y = p.y * m.d + m.f; + if (x > r.left && x < r.right && y > r.top && y < r.bottom) hit = { from: a, to: b, wire: c }; + } + if (hit) break; + } + // a Wait can't follow or lead into another Wait + if (hit && kindOf(from) === "wait" && [hit.from, hit.to].some((n) => kindOf(df.getNodeFromId(n)) === "wait")) hit = null; + if (insertAt?.wire !== hit?.wire) insertAt?.wire.classList.remove("insert-at"); + hit?.wire.classList.add("insert-at"); + node.classList.toggle("inserting", !!hit); + insertAt = hit; + }, + + insertInto(id) { + const { from, to, wire } = insertAt; + insertAt = null; + wire.classList.remove("insert-at"); + const node = document.getElementById("node-" + id); + node.classList.remove("inserting"); + df.removeSingleConnection(from, to, "output_1", "input_1"); + df.addConnection(from, id, "output_1", "input_1"); + df.addConnection(id, to, "output_1", "input_1"); + node.classList.add("dropped-in"); + setTimeout(() => node.classList.remove("dropped-in"), 400); + }, + + // generic drag helper: calls move(dx, dy) in screen pixels since the last event + drag(e, cls, move, done) { + const el = df.container; + let lx = e.clientX, ly = e.clientY; + el.classList.add(cls); + const onMove = (ev) => { move(ev.clientX - lx, ev.clientY - ly, ev); lx = ev.clientX; ly = ev.clientY; this.drawMinimap(); }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + el.classList.remove(cls); + done?.(); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp, { once: true }); + }, + + moveNodeTo(id, x, y) { + const n = df.drawflow.drawflow.Home.data[id]; + if (!n) return; + n.pos_x = x; n.pos_y = y; + const el = document.getElementById("node-" + id); + el.style.left = x + "px"; + el.style.top = y + "px"; + df.updateConnectionNodes("node-" + id); + }, + + moveNodeBy(id, dx, dy) { + const n = df.drawflow.drawflow.Home.data[id]; + if (n) this.moveNodeTo(id, n.pos_x + dx, n.pos_y + dy); + }, + + rectOf(id) { + const n = df.drawflow.drawflow.Home.data[id], el = document.getElementById("node-" + id); + return { x: n.pos_x, y: n.pos_y, w: el.offsetWidth, h: el.offsetHeight }; + }, + + // moves a new node to the nearest spot around (x, y) where it doesn't cover anything + settle(id, x, y) { + const me = this.rectOf(id), pad = 28; + const others = Object.keys(df.drawflow.drawflow.Home.data).filter((k) => k !== String(id)).map((k) => this.rectOf(k)); + const free = (px, py) => others.every((o) => px + me.w + pad <= o.x || o.x + o.w + pad <= px || py + me.h + pad <= o.y || o.y + o.h + pad <= py); + for (let ring = 0; ring < 40; ring++) { + const steps = Math.max(1, ring * 8); + for (let i = 0; i < steps; i++) { + const a = (i / steps) * Math.PI * 2; + const px = x + Math.cos(a) * ring * 60, py = y + Math.sin(a) * ring * 45; + if (free(px, py)) return this.moveNodeTo(id, px, py); + } + } + this.moveNodeTo(id, x, y); + }, + + paintSelection() { + document.querySelectorAll(".drawflow-node.is-sel").forEach((n) => n.classList.remove("is-sel")); + for (const id of [...selected]) { + const n = document.getElementById("node-" + id); + if (n) n.classList.add("is-sel"); else selected.delete(id); + } + this.selectionSize = selected.size; + const first = [...selected][0]; + if (first && owner.has(first)) this.selectedFile = this.fileOf(owner.get(first)); + }, + + isTyping(t) { + if (!t || t.classList?.contains("ed-keys")) return false; + return !!(t.matches?.('input:not([type="checkbox"]):not([type="radio"]), textarea, select') || t.closest?.('[contenteditable="true"]')); + }, + + // Ctrl+C, X and V go through the browser's clipboard events, so they also work between tabs; fields keep their own + onClipboard(e) { + if (this.status !== "online" || this.isTyping(e.target)) return; + if (e.type === "paste") { + let data = null; + try { data = JSON.parse(e.clipboardData.getData("text/plain")).cbEditor; } catch {} + if (data?.nodes) clipboard = data; + if (!clipboard) return; + e.preventDefault(); + this.paste(mouse && this.toWorld(mouse.x, mouse.y)); + return; + } + if (!selected.size) return; + e.preventDefault(); + this.copy(); + e.clipboardData.setData("text/plain", JSON.stringify({ cbEditor: clipboard })); + if (e.type === "cut") this.deleteSelection(); + }, + + // the usual node-editor keys; fields keep theirs, except Ctrl+S and Escape + onKey(e) { + if (this.status !== "online") return; + const t = e.target; + const typing = this.isTyping(t); + const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase(); + if (k === "escape") { + if (this.yamlSugg) return; // the YAML completion list closes first + if (this.pop) this.pop = null; + else if (typing) t.blur(); + else { selected.clear(); this.paintSelection(); this.wireX = null; } + return; + } + if (mod && k === "s") { e.preventDefault(); this.save(); return; } + if (typing) return; + if (mod) { + const run = { + z: () => (e.shiftKey ? this.redo() : this.undo()), y: () => this.redo(), a: () => this.selectAll(), + d: () => this.duplicate(), + g: () => (e.shiftKey ? this.ungroupSelection() : this.newGroup()), + 0: () => this.zoomAt(1, df.container.clientWidth / 2, df.container.clientHeight / 2), + "=": () => this.zoomBy(1.2), "+": () => this.zoomBy(1.2), "-": () => this.zoomBy(1 / 1.2), + }[k]; + if (run) { e.preventDefault(); run(); } + return; + } + if (k === "delete" || k === "backspace") { + // Drawflow would delete only the node it thinks is selected + e.stopPropagation(); e.preventDefault(); + if (selected.size) this.deleteSelection(); else this.deleteWire(); + return; + } + const step = e.shiftKey ? 50 : 10; + const nudge = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[k]; + if (nudge && selected.size) { + e.preventDefault(); + for (const id of selected) this.moveNodeBy(id, ...nudge); + this.refresh(); + return; + } + if (e.code === "Space") { df.container.classList.add("ed-space"); e.preventDefault(); return; } + if (k === "f") this.fit(selected.size ? [...selected] : null); + else if (k === "v") this.setTool("select"); + else if (k === "h") this.setTool("grab"); + else if (k === "a") { e.preventDefault(); this.addMenu(); } + }, + + selectAll() { + for (const id of Object.keys(df.drawflow.drawflow.Home.data)) selected.add(id); + this.paintSelection(); + }, + + setTool(t) { + this.tool = t; + df.container.classList.toggle("ed-grab", t === "grab"); + }, + + copy() { + if (!selected.size) return; + const snap = JSON.parse(this.serialize()); + clipboard = { nodes: snap.nodes.filter((n) => selected.has(n.id)), edges: snap.edges.filter(([a, , b]) => selected.has(a) && selected.has(b)) }; + }, + + // pastes at a canvas point, or next to the originals; a pasted Command is a new script (refresh gives it a group) + paste(at) { + if (!clipboard?.nodes.length) return; + const x0 = Math.min(...clipboard.nodes.map((n) => n.x)), y0 = Math.min(...clipboard.nodes.map((n) => n.y)); + const origin = at || { x: x0 + 60, y: y0 + 60 }; + const taken = new Set(Object.values(df.drawflow.drawflow.Home.data).filter((n) => kindOf(n) === "command").map((n) => this.fields(n.id).name)); + const ids = new Map(); + selected.clear(); + for (const n of clipboard.nodes) { + const values = { ...n.values }; + if (n.kind === "command") { + let name = values.name + "-copy", i = 2; + while (taken.has(name) || this.scripts[name + ".yml"] !== undefined) name = `${values.name}-copy-${i++}`; + taken.add(values.name = name); + } + const id = this.place(n.kind, origin.x + n.x - x0, origin.y + n.y - y0, values); + ids.set(n.id, id); + selected.add(id); + } + for (const [a, out, b, inp] of clipboard.edges) df.addConnection(ids.get(a), ids.get(b), out, inp); + this.paintSelection(); + this.syncCalls(); + this.refresh(); + }, + + cut() { + this.copy(); + this.deleteSelection(); + }, + + duplicate() { + const keep = clipboard; + this.copy(); + this.paste(); + clipboard = keep; + }, + + deleteSelection() { + const ids = [...selected]; + selected.clear(); + this.removingNodes = true; // their wires go too, which must not rewrite other scripts' commands + for (const id of ids) if (df.drawflow.drawflow.Home.data[id]) df.removeNodeId("node-" + id); + this.removingNodes = false; + this.paintSelection(); + this.refresh(); + }, + + // deletes the wire the button was shown for; Drawflow may have dropped its own selection by now + deleteWire() { + if (wireShown?.isConnected) { + df.connection_selected = wireShown; + df.removeConnection(); + } + wireShown = null; + this.wireX = null; + }, + + /* ---------- port menus: what can be added or wired at a port ---------- */ + + portMenu(nodeId, side, port, sx, sy, dropped = false) { + const n = df.getNodeFromId(nodeId), kind = kindOf(n); + const label = portLabel(kind, side, port); + const type = portType(kind, side, port); + const at = dropped ? this.toWorld(sx, sy) : null; + const r = this.rectOf(nodeId); + const right = at || { x: r.x + r.w + 90, y: r.y }; + const left = (w) => at || { x: r.x - w - 90, y: r.y }; + const below = at || { x: r.x, y: r.y + r.h + 40 }; + const items = []; + const data = df.drawflow.drawflow.Home.data; + const script = owner.get(String(nodeId)); + const wireIn = (k, values, pos) => () => { const id = this.addNode(k, values, pos.x, pos.y); df.addConnection(id, nodeId, "output_1", port); this.refresh(); return id; }; + + if (side === "out" && type === "flow") { + const next = n.outputs[port].connections[0]?.node; + const insert = (k) => () => { + const id = this.addNode(k, {}, right.x, right.y); + if (next) df.removeSingleConnection(nodeId, next, port, "input_1"); + df.addConnection(nodeId, id, port, "input_1"); + if (next) df.addConnection(id, next, "output_1", "input_1"); + this.refresh(); + }; + items.push({ kind: "run", label: "Execute step", run: insert("run") }); + if (kind !== "wait") items.push({ kind: "wait", label: "Wait", run: insert("wait") }); + } else if (side === "out" && type === "call") { + for (const c of Object.values(data).filter((c) => kindOf(c) === "command" && String(c.id) !== script)) { + items.push({ kind: "command", label: "/" + this.fields(c.id).name, run: () => df.addConnection(nodeId, c.id, port, P.cmd.calledBy) }); + } + if (!items.length) items.push({ label: "No other scripts yet", disabled: true }); + } else if (side === "in" && type === "call") { + for (const c of Object.values(data).filter((c) => kindOf(c) === "run" && owner.get(String(c.id)) !== String(nodeId))) { + const s = owner.get(String(c.id)); + items.push({ kind: "run", label: `/${s ? this.fields(s).name : "?"}: ${this.fields(c.id).command || "(empty step)"}`, run: () => df.addConnection(c.id, nodeId, P.run.calls, port) }); + } + if (!items.length) items.push({ label: "No steps in other scripts yet", disabled: true }); + } else if (side === "in" && type === "arg") { + items.push({ kind: "arg", label: "New argument", run: () => { + // below the existing arguments, because their order on the canvas is their order in the command + const y = n.inputs[port].connections.map((c) => this.rectOf(c.node)).reduce((m, q) => Math.max(m, q.y + q.h + 30), r.y); + wireIn("arg", {}, at || { x: r.x - 380 - 90, y })(); + } }); + } else if (side === "in" && type === "alias") { + items.push({ kind: "alias", label: "New aliases", run: wireIn("alias", {}, left(300)) }); + } else if (side === "in" && type === "def") { + items.push({ kind: "defaults", label: "New defaults", run: wireIn("defaults", {}, below) }); + for (const d of Object.values(data).filter((d) => kindOf(d) === "defaults")) { + const users = d.outputs.output_1.connections.map((c) => "/" + this.fields(c.node).name).join(", "); + items.push({ kind: "defaults", label: "Reuse defaults", hint: users ? "used by " + users : "not used yet", run: () => { df.addConnection(d.id, nodeId, "output_1", port); this.refresh(); } }); + } + } else if (side === "in" && type === "runas") { + for (const v of RUN_AS) items.push({ icon: RUN_AS_ICONS[v], color: MINIMAP_COLORS.runas, label: v.toLowerCase(), run: wireIn("runas", { value: v }, below) }); + } else if (side === "in" && type === "cool") { + items.push({ kind: "cooldown", label: "New cooldown", run: wireIn("cooldown", { cooldown: "10s" }, below) }); + } else if (side === "in" && type === "rules") { + items.push({ kind: "rules", label: "New server rules", run: wireIn("rules", {}, below) }); + } else if (side === "in" && type === "srv") { + const wired = new Set(n.inputs[port].connections.map((c) => this.target(c.node)).map((t) => `${t.id}@${t.location}`)); + items.push(...this.serverItems((t) => this.wireServer(nodeId, port, t, left(240)), wired)); + } else if (side === "out" && type === "def") { + for (const c of Object.values(data).filter((c) => kindOf(c) === "command" && !c.inputs[P.cmd.defaults].connections.length)) { + items.push({ kind: "command", label: "/" + this.fields(c.id).name, run: () => { df.addConnection(nodeId, c.id, "output_1", P.cmd.defaults); this.refresh(); } }); + } + if (!items.length) items.push({ label: "Every script already has defaults", disabled: true }); + } else { + const hints = { flow: "Connect it from a step's 'then'", arg: "Drag it into a Command's 'arguments'", srv: "Drag it into 'registered on' or 'targets'", + alias: "Drag it into a Command's 'aliases'", runas: "Drag it into 'run as'", cool: "Drag it into a step's 'cooldown'", rules: "Drag it into a step's 'server rules'" }; + items.push({ label: hints[type] || "Drag from here to connect", disabled: true }); + } + this.openMenu(sx, sy, items, label); + }, + + // shows a menu at a screen point, kept inside the work area; submenus open to the left near the right edge + openMenu(sx, sy, items, title = "") { + const p = this.toWork(sx, sy); + this.pop = { x: p.x, y: p.y, title, items, flip: false, open: null }; + this.$nextTick(() => { + const m = document.querySelector(".ed-pop"), w = df.container.parentElement; + if (!m || !this.pop) return; + this.pop.x = Math.max(4, Math.min(p.x, w.clientWidth - m.offsetWidth - 16)); + this.pop.y = Math.max(4, Math.min(p.y, w.clientHeight - m.offsetHeight - 16)); + this.pop.flip = this.pop.x + m.offsetWidth * 2 > w.clientWidth; + }); + }, + + // safe triangle: while the pointer heads for the open submenu, crossing other items doesn't switch it + aimMove(e) { + aim.p0 = aim.p1; + aim.p1 = { x: e.clientX, y: e.clientY }; + aim.t = Date.now(); + }, + + aim(i) { + clearTimeout(aim.timer); + const sub = document.querySelector(".ed-pop > .ed-mw.open > .ed-sub"); + if (sub && this.pop.open !== i && this.headingFor(sub)) { aim.timer = setTimeout(() => this.aim(i), 120); return; } + this.pop.open = i; + }, + + headingFor(sub) { + const { p0: a, p1: p } = aim; + if (!a || !p || Date.now() - aim.t > 100) return false; // the pointer rests: it's where the user wants to be + const r = sub.getBoundingClientRect(), x = this.pop.flip ? r.right : r.left; + const b = { x, y: r.top - 8 }, c = { x, y: r.bottom + 8 }; + const side = (u, v, w) => (v.x - u.x) * (w.y - u.y) - (v.y - u.y) * (w.x - u.x); + const d1 = side(a, b, p), d2 = side(b, c, p), d3 = side(c, a, p); + return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0)); + }, + + icon(kind) { return ICONS[kind] || ""; }, + color(kind) { return MINIMAP_COLORS[kind]; }, + + // what can be added, at a canvas point or in the middle of the view + addItems(at) { + const node = (kind, label) => ({ kind, label, run: () => this.addAt(kind, {}, at) }); + return [ + { kind: "command", label: "Command", run: () => this.addScript(at) }, + { kind: "run", label: "Step", sub: [node("run", "Execute"), node("wait", "Wait")] }, + { kind: "arg", label: "Input", sub: [node("arg", "Argument"), node("alias", "Aliases")] }, + { kind: "defaults", label: "Setting", sub: [node("defaults", "Defaults"), node("runas", "Run as"), node("cooldown", "Cooldown"), node("rules", "Server rules")] }, + { kind: "server", label: "Server", sub: this.serverItems((t) => this.addAt("server", t, at)) }, + ]; + }, + + addAt(kind, values, at) { + const c = at || this.viewCenter(); + const id = this.addNode(kind, values, c.x, c.y); + selected.clear(); selected.add(id); this.paintSelection(); + this.refresh(); + return id; + }, + + // the toolbar's Add button and the A key + addMenu(e) { + const r = (e?.currentTarget || document.querySelector(".ed-addbtn")).getBoundingClientRect(); + this.openMenu(r.left, r.bottom - 4, this.addItems(null)); + }, + + contextMenu(t, sx, sy) { + const at = this.toWorld(sx, sy); + const nodeEl = t.closest(".drawflow-node"), wire = t.closest(".connection"), frame = t.closest(".grp"); + const ic = (d) => svg(d, 15); + const I = { + copy: ic(''), + cut: ic(''), + paste: ic(''), + dup: ic(''), + trash: ic(''), + power: ic(''), + group: ic(''), + ungroup: ic(''), + tidy: ic(''), + fit: ic(''), + all: ic(''), + dot: ic(''), + }; + // the icon shows what the item does: switch off (crossed out) or switch on (green) + const onOff = (on, what, run) => (on ? { icon: svg(LUCIDE["power-off"]), label: `Disable ${what}`, run } : { icon: I.power, label: `Enable ${what}`, run }); + const paste = { icon: I.paste, label: "Paste", keys: "Ctrl+V", disabled: !clipboard, run: () => this.paste(at) }; + let items, title = ""; + if (wire) { + this.selectWire(t, sx, sy); + items = [{ icon: I.trash, label: "Delete connection", keys: "Del", danger: true, run: () => this.deleteWire() }]; + } else if (nodeEl) { + const id = nodeEl.id.slice(5); + if (!selected.has(id)) { selected.clear(); selected.add(id); this.paintSelection(); } + const n = selected.size, kind = kindOf(df.getNodeFromId(id)); + title = n > 1 ? `${n} nodes` : kind === "command" ? "/" + this.fields(id).name : TITLES[kind]; + const sw = kind === "command" && n === 1 && nodeEl.querySelector('[data-f="enabled"]'); + const sids = this.selectedFiles(), inGroups = new Set(sids.map((f) => this.groupOf(f))); + const one = inGroups.size === 1 ? [...inGroups][0] : null, shared = [...inGroups].some((g) => g?.members.length > 1); + items = [ + { icon: I.copy, label: "Copy", keys: "Ctrl+C", run: () => this.copy() }, + { icon: I.cut, label: "Cut", keys: "Ctrl+X", run: () => this.cut() }, + { icon: I.dup, label: "Duplicate", keys: "Ctrl+D", run: () => this.duplicate() }, + paste, + { sep: true }, + // the group of the script(s) behind the selection: shown, changed, or left + ...(sids.length ? [{ icon: I.group, label: "Group", hint: one?.title || "", sub: [ + { section: sids.length > 1 ? `Move ${sids.length} scripts to` : "Move the script to" }, + ...groups.map((g) => ({ icon: I.dot, color: g.color, label: g.title || "Group", check: g === one, disabled: g === one, run: () => this.moveToGroup(sids, g) })), + { sep: true }, + { icon: I.group, label: "New group", keys: "Ctrl+G", run: () => this.newGroup() }, + ...(shared ? [{ icon: I.ungroup, label: "Remove from group", keys: "Ctrl+Shift+G", run: () => this.ungroupSelection() }] : []), + ] }] : []), + ...(shared ? [{ icon: I.ungroup, label: "Remove from group", keys: "Ctrl+Shift+G", run: () => this.ungroupSelection() }] : []), + ...(sw ? [onOff(sw.checked, "script", () => { sw.checked = !sw.checked; this.refresh(); })] : []), + { sep: true }, + { icon: I.trash, label: n > 1 ? `Delete ${n} nodes` : "Delete", keys: "Del", danger: true, run: () => this.deleteSelection() }, + ]; + } else { + const g = frame && this.groupById(frame.dataset.gid); + items = [ + ...this.addItems(at), + { sep: true }, + paste, + { icon: I.all, label: "Select all", keys: "Ctrl+A", run: () => this.selectAll() }, + { sep: true }, + ...(g ? [ + { section: g.title || "Group" }, + onOff(this.groupEnabled(g), "group", () => this.setGroupEnabled(g, !this.groupEnabled(g))), + { icon: I.tidy, label: "Sort group", run: () => this.sortGroup(g) }, + ...(g.members.length > 1 ? [{ icon: I.ungroup, label: "Ungroup", danger: true, run: () => this.ungroup(g.members) }] : []), + { sep: true }, + ] : []), + { icon: I.tidy, label: "Sort everything", run: () => this.tidy() }, + { icon: I.fit, label: "Fit view", keys: "F", run: () => this.fit() }, + ]; + } + this.openMenu(sx, sy, items, title); + }, + + runPop(item) { + if (item.disabled || item.sub) return; + this.pop = null; + item.run(); + }, + + // connected servers, then the ones scripts use that aren't connected, then a field for any other id + serverItems(pick, skip = new Set()) { + const items = []; + for (const online of [true, false]) { + const list = [...this.knownServers()].filter(([id, info]) => info.online === online && !skip.has(`${id}@${info.location}`)); + if (!list.length) continue; + items.push({ section: online ? "Connected" : "Offline" }); + for (const [id, info] of list) { + items.push({ icon: srvIcon(info.location, info.platform), color: online ? SRV_COLORS[info.location] : null, label: id, muted: !online, run: () => pick({ id, location: info.location }) }); + } + } + items.push({ sep: true }, { custom: true, run: pick }); + return items; + }, + + srvIcon(loc) { return srvIcon(loc); }, + + customServer(e, item) { + const input = e.target.elements.sid, id = input.value.trim(); + if (!RULES.serverid[0].test(id)) { input.classList.add("bad"); input.focus(); return; } + this.pop = null; + item.run({ id, location: e.submitter?.value || "BACKEND" }); + }, + + // one Server node per use, like a variable, so its wire stays short and crosses nothing + wireServer(nodeId, port, t, pos) { + df.addConnection(this.addNode("server", { id: t.id, location: t.location }, pos.x, pos.y), nodeId, "output_1", port); + this.refresh(); + }, + + // servers the proxy knows: itself and connected clients, plus every id the scripts already use + knownServers() { + const out = new Map(); + for (const [id, info] of Object.entries(this.servers)) out.set(id, { location: info.location, platform: info.platform, online: true }); + for (const text of Object.values(this.scripts)) { + let m; + try { m = conv.toModel(YAML, text); } catch { continue; } + for (const t of [...m.register, ...(m.defaults.execute || []), ...m.commands.flatMap((c) => c.execute || [])]) { + if (t?.id && !out.has(t.id)) out.set(String(t.id), { location: t.location || "BACKEND", online: false }); + } + } + return new Map([...out].sort((a, b) => Number(b[1].online) - Number(a[1].online) || a[0].localeCompare(b[0]))); + }, + + /* ---------- fields: lists, suggestions, validation ---------- */ + + onClick(e) { + const t = e.target; + // the switch shows whether any script in the group is on; a click turns them all the other way + if (t.classList.contains("grp-en")) { const g = this.groupById(t.closest(".grp").dataset.gid); this.setGroupEnabled(g, !this.groupEnabled(g)); return; } + if (t.classList.contains("grp-x")) { this.ungroup(this.groupById(t.closest(".grp").dataset.gid).members); return; } + if (t.closest(".grp-sort")) { this.sortGroup(this.groupById(t.closest(".grp").dataset.gid)); return; } + if (t.classList.contains("grp-c")) { this.groupById(t.closest(".grp").dataset.gid).color = t.dataset.color; this.renderGroups(); this.refresh(); return; } + if (t.closest(".tag-x")) { + const box = t.closest(".sec"); + t.closest(".tag").remove(); + this.saveTags(box); + return; + } + const pk = t.closest(".pick"); + if (pk) { this.pickMenu(pk); return; } + // a click on a field's frame, like the "/" before a name, goes into the field + const frame = t.closest(".slashed, .tag, .al-new"); + if (frame && t.tagName !== "INPUT" && !t.closest("button")) { + const input = frame.querySelector("input"); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + } + }, + + onInput(e) { + const t = e.target; + if (t.classList.contains("grp-t")) { + Object.assign(this.groupById(t.closest(".grp").dataset.gid), { title: t.value, named: true }); + t.size = Math.max(6, t.value.length + 1); + } + if (t.classList.contains("tag-in")) return; + if (t.classList.contains("al-in")) { t.closest(".tag").dataset.v = t.value.trim().replace(/^\//, ""); this.check(t); this.saveTags(t.closest(".sec")); return; } + if (t.dataset.f === "name" && t.closest(".cb-command")) t.closest(".drawflow-node").querySelector(".nh-name").textContent = t.value; + if (t.tagName === "TEXTAREA") this.grow(t); + if (t.classList.contains("cmd")) this.tokenize(t); + if (t.dataset.rule && RULES[t.dataset.rule]) this.check(t); + if (t.dataset.rule === "duration" && t === document.activeElement) this.durationMenu(t); + this.refresh(t.type === "text" || t.tagName === "TEXTAREA" || t.classList.contains("cmd")); + }, + + onChange(e) { + const t = e.target; + if (["name"].includes(t.dataset.f)) this.syncCalls(); + df.updateConnectionNodes(t.closest(".drawflow-node")?.id || ""); + this.refresh(); + }, + + onFieldKey(e) { + const t = e.target; + // single-line fields that wrap: Enter leaves them instead of breaking the line + if (e.key === "Enter" && (t.tagName === "TEXTAREA" || t.classList.contains("cmd"))) { e.preventDefault(); t.blur(); return; } + if (e.key === "Enter" && t.classList.contains("al-in")) { e.preventDefault(); t.closest(".sec").querySelector(".tag-in").focus(); return; } + if (!t.classList.contains("tag-in")) return; + if (e.key === "Enter" || e.key === "," || e.key === " " || e.key === "Tab") { + if (t.value.trim()) { e.preventDefault(); this.commitTag(t); } + } else if (e.key === "Backspace" && !t.value) { + const box = t.closest(".sec"), last = box.querySelector(".als").lastElementChild; + if (last) { last.remove(); this.saveTags(box); } + } + }, + + // an alias typed into the tag field becomes a tag + commitTag(input) { + const v = input.value.trim().replace(/^\//, ""); + input.value = ""; + const box = input.closest(".sec"); + if (!v || box.querySelector(`.tag[data-v="${CSS.escape(v)}"]`)) return; + box.querySelector(".als").insertAdjacentHTML("beforeend", tag(v)); + this.saveTags(box); + }, + + saveTags(box) { + box.querySelector('[data-f="aliases"]').value = [...box.querySelectorAll(".tag")].map((t) => t.dataset.v).filter(Boolean).join(","); + df.updateConnectionNodes(box.closest(".drawflow-node").id); + this.syncCalls(); + this.refresh(); + }, + + grow(area) { + area.style.height = "auto"; + area.style.height = area.scrollHeight + 2 + "px"; + df.updateConnectionNodes(area.closest(".drawflow-node").id); + }, + + // the command text without its pieces: placeholders back to ${name} + cmdText(box) { + let out = ""; + for (const n of box.childNodes) { + out += n.nodeType === 3 ? n.nodeValue : n.classList?.contains("ph") ? "${" + n.dataset.arg + "}" : n.classList?.contains("callw") ? n.dataset.word : this.cmdText(n); + } + return out.replace(/\u00a0/g, " "); + }, + + setCmd(box, text) { + box.innerHTML = cmdHtml(text, box.classList.contains("calling")); + }, + + // a typed ${name} becomes a placeholder piece; the caret stays where it was in the text + tokenize(box) { + if (![...box.childNodes].some((n) => n.nodeType === 3 && /\$\{[^}]*\}/.test(n.nodeValue)) && !box.querySelector(":scope > :not(.ph):not(.callw)")) return; + const sel = getSelection(), range = sel.rangeCount && sel.getRangeAt(0); + let caret = null; + if (range && box.contains(range.startContainer)) { + const pre = document.createRange(); + pre.selectNodeContents(box); + pre.setEnd(range.startContainer, range.startOffset); + const tmp = document.createElement("div"); + tmp.appendChild(pre.cloneContents()); + caret = this.cmdText(tmp).length; + } + this.setCmd(box, this.cmdText(box)); + if (caret === null) return; + let left = caret; + for (const n of box.childNodes) { + const len = n.nodeType === 3 ? n.nodeValue.length : this.cmdText({ childNodes: [n] }).length; + if (left <= len) { + const r = document.createRange(); + if (n.nodeType === 3) r.setStart(n, left); else r.setStartAfter(n); + r.collapse(true); + sel.removeAllRanges(); sel.addRange(r); + return; + } + left -= len; + } + }, + + // drags an argument (a pill, or a piece already in a command) between the words of a command; the text makes room + // as it moves. A piece dragged out lands in the remove zone, or anywhere else to go back where it was. + pieceDrag(e, el) { + e.stopPropagation(); e.preventDefault(); + this.tip = null; + const name = el.dataset.arg, from = el.classList.contains("ph") ? el : null, home = from?.closest(".cmd"); + const x0 = e.clientX, y0 = e.clientY, r0 = el.getBoundingClientRect(); + const PREVIEW = "\u0000"; + let ghost = null, trash = null, box = null, tokens = [], slots = [], slot = -1, homeText = "", overTrash = false; + const render = (b, list) => { b.innerHTML = list.map((t) => (t === PREVIEW ? `${esc(name)}` : `${textHtml(t)}`)).join(" "); }; + const leave = () => { if (box) { this.setCmd(box, tokens.join(" ")); box.classList.remove("drop-here"); } box = null; slot = -1; }; + const enter = (b) => { + box = b; + const text = this.cmdText(b).trim(); + tokens = text ? text.split(/\s+/) : []; // one space between words, however many there were + render(b, tokens); + b.classList.add("drop-here"); + const spans = [...b.querySelectorAll(".tk")].map((s) => s.getBoundingClientRect()); + // slot i goes before word i; a step that calls a script keeps the called name first + slots = spans.map((r) => ({ x: r.left - 3, y: r.top + r.height / 2 })); + const last = spans.at(-1), br = b.getBoundingClientRect(); + slots.push(last ? { x: last.right + 3, y: last.top + last.height / 2 } : { x: br.left + 10, y: br.top + br.height / 2 }); + if (b.classList.contains("calling") && slots.length > 1) slots[0] = null; + }; + const start = () => { + // the ghost is the piece as it looks in the text, at the canvas zoom, held where it was grabbed + ghost = document.createElement("div"); + ghost.className = "ph-ghost"; + ghost.textContent = name; + ghost.style.transform = `scale(${df.zoom})`; + document.body.append(ghost); + ghost.dataset.dx = x0 - r0.left; ghost.dataset.dy = y0 - r0.top; + if (from) { + homeText = this.cmdText(home); + from.remove(); + this.setCmd(home, this.cmdText(home).trim().replace(/\s+/g, " ")); + trash = document.createElement("div"); + trash.className = "ed-trash"; + trash.innerHTML = `${svg(LUCIDE["trash-2"], 18)}Drop here to remove`; + document.querySelector(".ed-stage").append(trash); + requestAnimationFrame(() => trash.classList.add("on")); + } else { + el.classList.add("lifted"); + } + df.container.classList.add("ed-moving"); + }; + const move = (ev) => { + if (!ghost) { if (Math.hypot(ev.clientX - x0, ev.clientY - y0) < 4) return; start(); } + ghost.style.left = ev.clientX - ghost.dataset.dx + "px"; + ghost.style.top = ev.clientY - ghost.dataset.dy + "px"; + const tr = trash?.getBoundingClientRect(); + overTrash = !!tr && ev.clientX > tr.left - 20 && ev.clientX < tr.right + 20 && ev.clientY > tr.top - 20 && ev.clientY < tr.bottom + 20; + trash?.classList.toggle("hot", overTrash); + ghost.classList.toggle("doomed", overTrash); + const under = overTrash ? null : document.elementFromPoint(ev.clientX, ev.clientY)?.closest("#ed-canvas .cmd"); + if (under !== box) { leave(); if (under) enter(under); } + if (!box) return; + let best = -1, dist = Infinity; + slots.forEach((p, i) => { if (!p) return; const d = Math.abs(p.x - ev.clientX) + Math.abs(p.y - ev.clientY) * 3; if (d < dist) { dist = d; best = i; } }); + if (best === slot) return; + slot = best; + const list = [...tokens]; + list.splice(slot, 0, PREVIEW); + render(box, list); + }; + const up = () => { + window.removeEventListener("mousemove", move); + df.container.classList.remove("ed-moving"); + el.classList.remove("lifted"); + if (!ghost) return; + let land = null; + if (box && slot >= 0) { + const target = box; + const list = [...tokens]; + list.splice(slot, 0, "${" + name + "}"); + box.classList.remove("drop-here"); + this.setCmd(target, list.join(" ")); + // the new piece is the one after as many same-named pieces as the words before it hold + const before = tokens.slice(0, slot).join(" ").split("${" + name + "}").length - 1; + land = target.querySelectorAll(`.ph[data-arg="${CSS.escape(name)}"]`)[before]; + box = null; + } else if (from && !overTrash) { + this.setCmd(home, homeText); // dropped nowhere: it goes back + land = [...home.querySelectorAll(".ph")].find((p) => p.dataset.arg === name); + } + // the ghost glides onto where the piece landed and hands over to it; in the bin or nowhere it fades + const to = land?.getBoundingClientRect(); + if (to) { + ghost.classList.add("settle"); + ghost.style.left = to.left + "px"; ghost.style.top = to.top + "px"; + ghost.addEventListener("transitionend", () => ghost.remove(), { once: true }); + setTimeout(() => ghost.remove(), 300); + } else { + ghost.classList.add("gone"); + setTimeout(() => ghost.remove(), 160); + } + if (trash) { trash.classList.remove("on"); setTimeout(() => trash.remove(), 220); } + this.syncCalls(); + this.refresh(); + df.container.querySelectorAll(".cb-run").forEach((n) => df.updateConnectionNodes(n.id)); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up, { once: true }); + }, + + // the player a server check is about has to be given, so the argument it comes from becomes required + requireArg(btn, name) { + const node = this.argNodesOf(btn.closest(".drawflow-node").id.slice(5)).find((n) => this.fields(n.id).name === name); + const sw = node && document.querySelector(`#node-${node.id} [data-f="required"]`); + if (sw) sw.checked = true; + }, + + // durations to pick from, narrowed down by what's typed; "none" where the field may stay empty + durationMenu(input) { + const v = input.value.trim().toLowerCase(); + const all = ["500ms", "1s", "3s", "5s", "10s", "30s", "1m", "5m", "10m", "30m", "1h", "6h", "12h", "24h"]; + const list = all.filter((d) => !v || (d.startsWith(v) && d !== v)); + const set = (d) => () => { input.value = d; input.dispatchEvent(new Event("input", { bubbles: true })); input.focus(); }; + const items = [...(!input.dataset.req && v ? [{ label: "none", muted: true, run: set("") }] : []), ...list.map((d) => ({ icon: svg(LUCIDE["clock-3"]), label: d, run: set(d) }))]; + if (!items.length) { this.pop = null; return; } + const r = input.getBoundingClientRect(); + this.openMenu(r.left, r.bottom + 2, items); + this.pop.scroll = true; + this.pop.width = Math.max(160, r.width); + this.pop.anchor = input; + }, + + // the editor's own dropdown: argument types by family, or the player the server checks are about + pickMenu(btn) { + if (btn.closest(".fr.idle")) return; + const r = btn.getBoundingClientRect(), cur = btn.dataset.v; + const set = (v, label, icon) => () => { btn.dataset.v = v; btn.querySelector("span").textContent = label; btn.querySelector(".pi").innerHTML = icon; this.refresh(); }; + let items; + if (btn.dataset.pick === "type") { + const types = window.cbArgTypes || []; + const used = new Set(); + const entry = (t) => ({ icon: typeIcon(t), label: t, check: t === cur, run: set(t, t, typeIcon(t)) }); + items = ARG_FAMILIES.flatMap(([section, list]) => { + const mine = list.filter((t) => types.includes(t)); + mine.forEach((t) => used.add(t)); + return mine.length ? [{ section }, ...mine.map(entry)] : []; + }); + const rest = types.filter((t) => !used.has(t)); + if (rest.length) items.push({ section: "Other" }, ...rest.map(entry)); + } else { + const args = this.argsOf(btn.closest(".drawflow-node").id.slice(5)); + items = [ + { icon: RUN_AS_ICONS.PLAYER, label: "who ran it", check: !cur, run: set("", "who ran it", RUN_AS_ICONS.PLAYER) }, + ...(args.length ? [{ section: "From an argument" }] : []), + ...args.map((a) => ({ icon: typeIcon(a.type), label: a.name, keys: a.type, check: a.name === cur, + disabled: !PLAYER_TYPES.includes(a.type), run: () => { this.requireArg(btn, a.name); set(a.name, a.name, svg(LUCIDE.users))(); } })), + ]; + } + this.openMenu(r.left, r.bottom + 2, items); + this.pop.scroll = true; + this.pop.width = Math.max(200, r.width); + }, + + + // a Server node's id and location + target(id) { + const f = this.fields(id); + return { id: f.id, location: f.location || "BACKEND" }; + }, + + check(input) { + const v = input.value.trim(); + const ok = v === "" ? !input.dataset.req : RULES[input.dataset.rule][0].test(v); + input.classList.toggle("bad", !ok); + if (ok) delete input.dataset.err; + else input.dataset.err = v === "" ? "Required" : "Use " + RULES[input.dataset.rule][1]; + }, + + // the argument nodes of the script a node belongs to, in their order + argNodesOf(nodeId) { + const cmd = owner.get(String(nodeId)); + if (!cmd) return []; + const data = df.drawflow.drawflow.Home.data; + return (data[cmd].inputs[P.cmd.args].connections || []).map((c) => data[c.node]).sort((a, b) => a.pos_y - b.pos_y); + }, + + argsOf(nodeId) { + return this.argNodesOf(nodeId).map((n) => this.fields(n.id)).filter((a) => a.name); + }, + + // what depends on the whole graph: argument chips, unknown placeholders, required fields and ports, muted scripts + refreshDynamic(scripts) { + const byCmd = new Map(scripts.map((s) => [s.id, s])); + const data = df.drawflow.drawflow.Home.data; + // a step's arguments, ready to be dragged into its command + document.querySelectorAll("#ed-canvas .args").forEach((box) => { + const s = byCmd.get(owner.get(box.closest(".drawflow-node").id.slice(5))); + const html = (s?.model.args || []).filter((a) => a.name).map((a) => `${esc(a.name)}`).join(""); + if (box.innerHTML !== html) box.innerHTML = html; + }); + const known = this.knownServers(); + // a connected server shows the software it runs; the rest show proxy or backend + document.querySelectorAll('#ed-canvas .cb-server [data-f="id"]').forEach((i) => { + const node = i.closest(".drawflow-node"), info = known.get(i.value), loc = node.querySelector('[data-f="location"]').value; + const platform = info?.location === loc ? info.platform : undefined; + node.classList.toggle("offline", !info?.online); + if (node.dataset.platform !== String(platform)) { + node.dataset.platform = String(platform); + node.querySelector(".nh > svg, .nh > .logo").outerHTML = srvIcon(loc, platform); + node.querySelector(".srv-k").textContent = srvKind(loc, platform); + } + }); + // "checks player" only matters while target required or schedule online is on + document.querySelectorAll('#ed-canvas [data-pick="player"]').forEach((b) => { + const f = this.fields(b.closest(".drawflow-node").id.slice(5)), p = b.dataset.f[0]; + b.closest(".fr").classList.toggle("idle", f[p + "-target"] !== "true" && f[p + "-online"] !== "true"); + }); + // a step that calls another script shows the called name as a fixed piece (not while it's being typed in) + for (const n of Object.values(data)) { + if (kindOf(n) !== "run") continue; + const box = document.querySelector(`#node-${n.id} .cmd`), calling = n.outputs[P.run.calls].connections.length > 0; + if (box && box !== document.activeElement && box.classList.contains("calling") !== calling) { + const text = this.cmdText(box); + box.classList.toggle("calling", calling); + this.setCmd(box, text); + } + } + // a step's unwired settings come from the Defaults: show what they'll be + for (const [nodeId, cmdId] of owner) { + const node = document.getElementById("node-" + nodeId), s = byCmd.get(cmdId); + if (!s || !node?.classList.contains("cb-run")) continue; + const d = s.model.defaults, n = data[nodeId]; + const inherited = { + targets: (d.execute || []).map((t) => t.id).join(", ") || "none", + "run as": (d["run-as"] || "CONSOLE").toLowerCase(), + cooldown: d.cooldown || "none", + "server rules": "defaults", + }; + for (const [label, value] of Object.entries(inherited)) { + const span = node.querySelector(`[data-port="${label}"]`), port = { targets: P.run.targets, "run as": P.run.runas, cooldown: P.run.cooldown, "server rules": P.run.rules }[label]; + const text = n.inputs[port].connections.length ? "" : value; + let em = span.querySelector(".inh"); + if (!text) { em?.remove(); continue; } + if (!em) { em = document.createElement("em"); em.className = "inh"; em.dataset.tip = "Not wired, so it uses the Defaults"; span.append(em); } + if (em.textContent !== text) em.textContent = text; + } + } + // a disabled script is muted with everything that only it uses + const off = new Set(scripts.filter((s) => !s.model.enabled).map((s) => s.id)); + for (const n of Object.values(data)) { + const own = owners.get(String(n.id)); + document.getElementById("node-" + n.id).classList.toggle("muted", !!own?.size && [...own].every((c) => off.has(c))); + } + document.querySelectorAll("#ed-canvas svg.connection").forEach((c) => { + const ids = [...c.classList].filter((k) => k.startsWith("node_")).map((k) => k.split("-")[1]); + c.classList.toggle("muted", ids.length === 2 && ids.every((i) => document.getElementById("node-" + i)?.classList.contains("muted"))); + }); + }, + + // red marks right on what needs a change, with the reason on hover; what has no place goes to the notes + markProblems(problems) { + document.querySelectorAll("#ed-canvas .pmark").forEach((el) => { el.classList.remove("pmark", "bad", "missing", "has-err"); delete el.dataset.err; }); + document.querySelectorAll("#ed-canvas [data-rule]").forEach((i) => this.check(i)); + const pinned = new Set(); + for (const p of problems) { + const node = p.at && document.getElementById("node-" + p.at.node); + let el = node && (p.at.port ? node.querySelector(`[data-port="${p.at.port}"]`) : node.querySelector(p.at.sel || ".nh")); + if (el && ["checkbox", "radio", "hidden"].includes(el.type)) el = el.closest(".fr") || el.closest(".sec"); + if (!el) continue; + pinned.add(p); + el.dataset.err = el.classList.contains("pmark") ? el.dataset.err + "\n" + p.text : p.text; + el.classList.add("pmark", p.at.port ? "missing" : el.classList.contains("nh") ? "has-err" : "bad"); + } + return pinned; + }, + + showTip(target, e) { + if (e?.buttons) { this.tip = null; return; } // never while dragging + const el = target.closest?.("[data-err], [data-tip]"); + const text = el && (el.dataset.err || el.dataset.tip); + if (!text || !el.closest(".ed")) { this.tip = null; return; } + const r = el.getBoundingClientRect(), w = document.querySelector(".ed").getBoundingClientRect(); + const right = r.left - w.left > w.width / 2; + this.tip = { text, err: !!el.dataset.err, y: r.bottom - w.top + 6, x: right ? w.right - r.right : r.left - w.left, right }; + }, + + saveTip() { + const b = this.blocking(); + if (!b.length) return ""; + return `Fix ${b.length} ${b.length === 1 ? "problem" : "problems"} before saving:\n` + b.slice(0, 5).map((p) => (p.script ? `/${p.script}: ` : "") + p.text).join("\n") + (b.length > 5 ? `\nand ${b.length - 5} more` : ""); + }, + + // clicking the blocked save button shows the first problem + revealProblem() { + const p = this.blocking().find((x) => x.pinned); + if (!p) return; + const r = this.rectOf(p.at.node); + this.centerOn(r.x + r.w / 2, r.y + r.h / 2, Math.max(df.zoom, 0.8)); + selected.clear(); selected.add(String(p.at.node)); this.paintSelection(); + }, + + groupById(id) { return groups.find((g) => g.id === id); }, + + // a group holds whole scripts: every node they use + nodesIn(g) { + const cmds = new Set(this.groupCommands(g).map((n) => String(n.id))); + return [...owners].filter(([, own]) => [...own].some((c) => cmds.has(c))).map(([id]) => id); + }, + + groupOf(sid) { return groups.find((g) => g.members.includes(sid)); }, + + // a script's id for its group: its file once saved, a random one until then, so renaming never moves it + sidOf(cmdId) { + const m = meta.get(String(cmdId)) || {}; + if (!m.sid && !m.file) meta.set(String(cmdId), { ...m, sid: crypto.randomUUID() }); + return meta.get(String(cmdId)).sid || m.file; + }, + + // the scripts the selected nodes belong to + selectedFiles() { + return [...new Set([...selected].filter((id) => owner.has(id)).map((id) => this.sidOf(owner.get(id))))]; + }, + + // a script is in exactly one group; a group left without scripts goes away + moveToGroup(files, g) { + for (const x of groups) if (x !== g) x.members = x.members.filter((m) => !files.includes(m)); + g.members = [...new Set([...g.members, ...files])]; + groups = groups.filter((x) => x.members.length); + this.renderGroups(); + this.refresh(); + }, + + // the selected scripts form a new group (Ctrl+G) + newGroup() { + const files = this.selectedFiles(); + if (!files.length) return; + const g = { id: crypto.randomUUID().slice(0, 8), title: "Group", color: GROUP_COLORS[groups.length % GROUP_COLORS.length], x: 0, y: 0, w: 0, h: 0, members: [] }; + groups.push(g); + this.moveToGroup(files, g); + document.querySelector(`.grp[data-gid="${g.id}"] .grp-t`)?.select(); + }, + + // scripts leaving a group go back to a group of their own, like every script starts + ungroup(files) { + for (const g of groups) g.members = g.members.filter((m) => !files.includes(m)); + groups = groups.filter((g) => g.members.length); + for (const f of files) { + const cmd = Object.values(df.drawflow.drawflow.Home.data).find((n) => kindOf(n) === "command" && this.sidOf(n.id) === f); + groups.push(this.scriptGroup(f, "/" + (cmd ? this.fields(cmd.id).name : f))); + } + this.renderGroups(); + this.refresh(); + }, + + ungroupSelection() { + const files = this.selectedFiles().filter((f) => this.groupOf(f)?.members.length > 1); + if (files.length) this.ungroup(files); + }, + + groupCommands(g) { + return Object.values(df.drawflow.drawflow.Home.data).filter((n) => kindOf(n) === "command" && g.members.includes(this.sidOf(n.id))); + }, + + groupEnabled(g) { + return this.groupCommands(g).some((n) => this.fields(n.id).enabled === "true"); + }, + + // a disabled group is every script in it disabled; nodes shared with an enabled script stay live + setGroupEnabled(g, on) { + for (const n of this.groupCommands(g)) document.querySelector(`#node-${n.id} [data-f="enabled"]`).checked = on; + this.refresh(); + }, + + scriptGroup(file, title) { + return { id: crypto.randomUUID().slice(0, 8), title, color: GROUP_COLORS[groups.length % GROUP_COLORS.length], x: 0, y: 0, w: 400, h: 200, members: [file], auto: true }; + }, + + // frames always wrap all of their scripts' nodes + fitGroups() { + const PAD = 40, HEAD = 44; + for (const g of groups) { + const ids = this.nodesIn(g); + if (!ids.length) continue; + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const id of ids) { const r = this.rectOf(id); x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y); x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h); } + Object.assign(g, { x: x0 - PAD, y: y0 - PAD - HEAD, w: x1 - x0 + PAD * 2, h: y1 - y0 + PAD * 2 + HEAD }); + } + }, + + // updates frames in place, so a title being typed keeps its focus + renderGroups() { + const seen = new Set(); + for (const g of groups) { + seen.add(g.id); + let el = df.precanvas.querySelector(`.grp[data-gid="${g.id}"]`); + if (!el) { + el = document.createElement("div"); + el.className = "grp"; + el.dataset.gid = g.id; + el.innerHTML = `
${GRIP} + ${GROUP_COLORS.map((c) => ``).join("")} + + +
`; + // behind the nodes, but above older frames + const frames = df.precanvas.querySelectorAll(".grp"); + frames.length ? frames[frames.length - 1].after(el) : df.precanvas.prepend(el); + } + Object.assign(el.style, { left: g.x + "px", top: g.y + "px", width: g.w + "px", height: g.h + "px" }); + el.style.setProperty("--g", g.color); + el.classList.toggle("solo", g.members.length === 1); + const on = this.groupEnabled(g); + el.querySelector(".grp-en").checked = on; + el.classList.toggle("off", !on); + el.querySelector(".grp-n").textContent = `${g.members.length} ${g.members.length === 1 ? "script" : "scripts"}`; + const title = el.querySelector(".grp-t"); + if (document.activeElement !== title && title.value !== g.title) { title.value = g.title; title.size = Math.max(6, g.title.length + 1); } + } + df.precanvas.querySelectorAll(".grp").forEach((n) => { if (!seen.has(n.dataset.gid)) n.remove(); }); + this.drawMinimap(); + }, + + // same-type ports only; the flow stays a single chain; single-value inputs take one wire + allowed(from, outPort, to, inPort, preview = false) { + if (String(from.id) === String(to.id)) return false; + const type = portType(kindOf(from), "out", outPort); + if (!type || type !== portType(kindOf(to), "in", inPort)) return false; + const already = preview ? 0 : 1; // after creation the new wire is already counted + const outs = from.outputs[outPort].connections, ins = to.inputs[inPort].connections; + if (["flow", "call", "alias", "arg"].includes(type) && outs.length > already) return false; + if (type === "flow" && ins.length > already) return false; + if (["def", "runas", "cool", "rules"].includes(type) && ins.length > already) return false; + if (kindOf(from) === "wait" && kindOf(to) === "wait") return false; + return true; + }, + + onConnect({ output_id, input_id, output_class, input_class }) { + if (building) { this.tagWire(output_id, input_id, output_class, input_class); return; } + const from = df.getNodeFromId(output_id), to = df.getNodeFromId(input_id); + if (!this.allowed(from, output_class, to, input_class)) { + df.removeSingleConnection(output_id, input_id, output_class, input_class); + return; + } + if (wiring) wiring.made = true; + this.tagWire(output_id, input_id, output_class, input_class); + const type = portType(kindOf(from), "out", output_class); + if (!syncingCalls && type === "call") { + // wiring a call writes the called command's name into the step and runs the step where that script is registered + // the name replaces a script name already in front, otherwise it goes in front of what's there + const runField = document.querySelector(`#node-${output_id} [data-f="command"]`); + const words = this.cmdText(runField).trim().split(/\s+/).filter(Boolean); + const scripts = new Set(Object.values(df.drawflow.drawflow.Home.data).filter((n) => kindOf(n) === "command").flatMap((n) => + [this.fields(n.id).name, ...n.inputs[P.cmd.aliases].connections.flatMap((c) => splitList(this.fields(c.node).aliases))]).map((x) => String(x).toLowerCase())); + if (words.length && scripts.has(words[0].replace(/^\//, "").toLowerCase())) words.shift(); + this.setCmd(runField, [this.fields(input_id).name, ...words].join(" ")); + const home = to.inputs[P.cmd.reg].connections[0]?.node; + if (home) { + const h = this.target(home); + const own = from.inputs[P.run.targets].connections.map((k) => String(k.node)); + if (!own.some((k) => { const t = this.target(k); return t.id === h.id && t.location === h.location; })) { + for (const k of own) df.removeSingleConnection(k, output_id, "output_1", P.run.targets); + const r = this.rectOf(output_id); + this.wireServer(output_id, P.run.targets, h, { x: r.x - 320 - 90, y: r.y + 40 }); + } + } + this.syncCalls(); + } else if (!syncingCalls && type === "srv") { + this.syncCalls(); // where a step runs decides whether it calls a script + } + this.refresh(); + }, + + // Drawflow removes a node's wires before it reports the node removed; deferring keeps the old + // ownership around so removing a Command can still take its own nodes with it + onDisconnect(c) { + if (building || syncingCalls) return; + const data = df.drawflow.drawflow.Home.data; + const from = data[c?.output_id]; + if (!this.removingNodes && from && data[c.input_id] && kindOf(from) === "run" && c.output_class === P.run.calls) { + // a call is the command text; removing the wire removes the called name from it, or it would come right back + const field = document.querySelector(`#node-${c.output_id} [data-f="command"]`); + this.setCmd(field, this.cmdText(field).trim().split(/\s+/).slice(1).join(" ")); + } + if (this.deferred) return; + this.deferred = true; + queueMicrotask(() => { + this.deferred = false; + this.syncCalls(); + this.refresh(); + }); + }, + + onNodeRemoved(id) { + if (building) return; + // removing a Command removes its script: the nodes only it uses go with it, shared ones stay + this.removingNodes = true; + for (const [nodeId, own] of owners) { + if (nodeId !== String(id) && own.has(String(id)) && own.size === 1 && df.drawflow.drawflow.Home.data[nodeId]) df.removeNodeId("node-" + nodeId); + } + this.removingNodes = false; + meta.delete(String(id)); + selected.delete(String(id)); + this.refresh(); + }, + + tagWire(outId, inId, outPort, inPort) { + const type = portType(kindOf(df.getNodeFromId(outId)), "out", outPort); + const wire = document.querySelector(`.connection.node_out_node-${outId}.node_in_node-${inId}.${outPort}.${inPort}`); + if (!wire) return; + wire.classList.add("t-" + type); + // a wide invisible copy to click on: the drawn wire is thin, and a dashed one has gaps clicks fall through. + // It goes after the drawn path, because Drawflow updates children[0] when nodes move. + if (!wire.querySelector(".hit-path")) { + const hit = wire.querySelector(".main-path").cloneNode(); + hit.setAttribute("class", "hit-path"); + wire.appendChild(hit); + } + }, + + /* ---------- nodes ---------- */ + + place(kind, x, y, values = {}) { + const p = PORTS[kind]; + const id = String(df.addNode(kind, p.in.length, p.out.length, x, y, "cb-" + kind, {}, nodeHtml(kind, values))); + const el = document.getElementById("node-" + id); + if (kind === "server") el.classList.toggle("proxy", srvLoc(values) === "VELOCITY"); + // shape and color per port type; appended so Drawflow's positional classes stay where it expects them + el.querySelectorAll(".inputs .input").forEach((n, i) => { n.classList.add("pt-" + p.in[i][1]); n.title = `${p.in[i][0]}: click to add, or drop a wire here. Alt-click removes its wires`; }); + el.querySelectorAll("textarea").forEach((a) => this.grow(a)); + el.querySelectorAll(".outputs .output").forEach((n, i) => { n.classList.add("pt-" + p.out[i][1]); n.title = `${p.out[i][0]}: drag to connect, click for options. Alt-click removes its wires`; }); + return id; + }, + + // a node added by hand goes into free space near where it was asked for + addNode(kind, values, x, y) { + const id = this.place(kind, x, y, values); + const el = document.getElementById("node-" + id); + el.classList.add("born"); + setTimeout(() => el.classList.remove("born"), 400); + this.settle(id, x, y); + return id; + }, + + fields(id) { + const out = {}; + document.querySelectorAll(`#node-${id} [data-f]`).forEach((el) => { + if (el.type === "radio") { if (el.checked) out[el.dataset.f] = el.value; return; } + if (el.dataset.pick) { out[el.dataset.f] = el.dataset.v; return; } + if (el.classList.contains("cmd")) { out[el.dataset.f] = this.cmdText(el).trim(); return; } + out[el.dataset.f] = el.type === "checkbox" ? String(el.checked) : el.value.trim(); + }); + return out; + }, + + // keeps the canvas point under (mx, my) in place; Drawflow scales around the precanvas centre + zoomAt(z, mx, my) { + z = Math.min(1.6, Math.max(0.15, z)); + const ox = df.precanvas.clientWidth / 2, oy = df.precanvas.clientHeight / 2; + const px = (mx - df.canvas_x - ox) / df.zoom + ox, py = (my - df.canvas_y - oy) / df.zoom + oy; + this.setView(z, mx - ox - (px - ox) * z, my - oy - (py - oy) * z); + }, + + setView(z, x, y) { + df.zoom = df.zoom_last_value = z; + df.canvas_x = x; + df.canvas_y = y; + df.precanvas.style.transform = `translate(${x}px, ${y}px) scale(${z})`; + this.zoom = Math.round(z * 100); + this.pop = null; + this.wireX = null; + this.drawMinimap(); + }, + + zoomBy(f) { this.zoomAt(df.zoom * f, df.container.clientWidth / 2, df.container.clientHeight / 2); }, + + bounds(ids = null) { + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const id of ids || Object.keys(df.drawflow.drawflow.Home.data)) { + const r = this.rectOf(id); + x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y); x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h); + } + if (!ids) for (const g of groups) { x0 = Math.min(x0, g.x); y0 = Math.min(y0, g.y); x1 = Math.max(x1, g.x + g.w); y1 = Math.max(y1, g.y + g.h); } + return x0 === Infinity ? null : { x0, y0, x1, y1 }; + }, + + centerOn(wx, wy, z = df.zoom) { + const ox = df.precanvas.clientWidth / 2, oy = df.precanvas.clientHeight / 2; + this.setView(z, df.container.clientWidth / 2 - ox - (wx - ox) * z, df.container.clientHeight / 2 - oy - (wy - oy) * z); + }, + + // the whole graph, or only the given nodes + fit(ids = null) { + const b = this.bounds(ids); + if (!b) return; + const w = df.container.clientWidth, h = df.container.clientHeight; + const z = Math.min(1, Math.max(0.15, Math.min((w - 80) / (b.x1 - b.x0 + 120), (h - 80) / (b.y1 - b.y0 + 120)))); + this.centerOn((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, z); + }, + + viewCenter() { + const ox = df.precanvas.clientWidth / 2, oy = df.precanvas.clientHeight / 2; + return { + x: (df.container.clientWidth / 2 - df.canvas_x - ox) / df.zoom + ox - 160, + y: (df.container.clientHeight / 3 - df.canvas_y - oy) / df.zoom + oy, + }; + }, + + drawMinimap() { + if (this.minimapQueued) return; + this.minimapQueued = true; + requestAnimationFrame(() => { + this.minimapQueued = false; + const c = document.getElementById("ed-minimap"); + if (!c || !this.showMinimap || !df) return; + const ctx = c.getContext("2d"), W = c.width, H = c.height; + ctx.clearRect(0, 0, W, H); + const b = this.bounds(); + if (!b) return; + const r = df.container.getBoundingClientRect(); + const v0 = this.toWorld(r.left, r.top), v1 = this.toWorld(r.right, r.bottom); + const x0 = Math.min(b.x0, v0.x), y0 = Math.min(b.y0, v0.y), x1 = Math.max(b.x1, v1.x), y1 = Math.max(b.y1, v1.y); + const s = Math.min((W - 16) / (x1 - x0), (H - 16) / (y1 - y0)); + const ox = (W - (x1 - x0) * s) / 2, oy = (H - (y1 - y0) * s) / 2; + const mx = (x) => ox + (x - x0) * s, my = (y) => oy + (y - y0) * s; + this.minimapMap = { x0, y0, s, ox, oy }; + for (const g of groups) { ctx.fillStyle = g.color + "33"; ctx.fillRect(mx(g.x), my(g.y), g.w * s, g.h * s); } + for (const n of Object.values(df.drawflow.drawflow.Home.data)) { + const rr = this.rectOf(n.id); + ctx.fillStyle = MINIMAP_COLORS[kindOf(n)]; + ctx.fillRect(mx(rr.x), my(rr.y), Math.max(2, rr.w * s), Math.max(2, rr.h * s)); + } + ctx.strokeStyle = getComputedStyle(c).color; + ctx.lineWidth = 1.5; + ctx.strokeRect(mx(v0.x), my(v0.y), (v1.x - v0.x) * s, (v1.y - v0.y) * s); + }); + }, + + minimapJump(e) { + const m = this.minimapMap; + if (!m) return; + const c = e.currentTarget, r = c.getBoundingClientRect(); + const go = (ev) => { + const px = (ev.clientX - r.left) * (c.width / r.width), py = (ev.clientY - r.top) * (c.height / r.height); + this.centerOn((px - m.ox) / m.s + m.x0, (py - m.oy) / m.s + m.y0); + }; + go(e); + const move = (ev) => go(ev); + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", () => window.removeEventListener("mousemove", move), { once: true }); + }, + + /* ================= scripts -> graph ================= */ + + // one row per script: servers, arguments and aliases, the command with its defaults, then its steps + // left to right with their own settings underneath + // texts: the scripts to lay out, by file; the saved ones unless the YAML panel hands in edited text + snapshotFromScripts(texts = this.scripts) { + const nodes = [], edges = [], unread = []; + let next = 0; + for (const file of Object.keys(texts).sort()) { + let m; + try { m = conv.toModel(YAML, texts[file]); } catch { unread.push(file); continue; } + const count = {}; + const add = (kind, values, extra = {}) => { + const key = kind === "command" ? "cmd" : kind + (count[kind] = (count[kind] ?? -1) + 1); + const n = { id: String(++next), kind, values, band: file, key: `${file}#${key}`, ...extra }; + nodes.push(n); + return n.id; + }; + const d = m.defaults, s = d.server || {}; + const cmd = add("command", { name: m.name, description: m.description || "", enabled: String(m.enabled !== false), perm: String(m.permissions.enabled !== false), silent: String(!!m.permissions.silent) }, { meta: this.scripts[file] !== undefined ? { file, version: m.version } : { version: m.version } }); + if (m.aliases.length) edges.push([add("alias", { aliases: m.aliases.join(", ") }), "output_1", cmd, P.cmd.aliases]); + m.args.forEach((a) => { + const id = add("arg", { name: a.name, type: a.type, required: String(!!a.required), suggestions: (a.suggestions || []).join(", ") }, { meta: { src: a._src } }); + edges.push([id, "output_1", cmd, P.cmd.args]); + }); + // one Server node per use, like a variable: every wire stays short. Added in the order collect() meets them, + // which is what the layout keys count by + const server = (t) => add("server", { id: String(t.id), location: t.location === "VELOCITY" ? "VELOCITY" : "BACKEND" }); + m.register.forEach((t) => edges.push([server(t), "output_1", cmd, P.cmd.reg])); + // every script has its Defaults node, so where steps run is never left open + { + const def = add("defaults", { "d-runas": d["run-as"] || "CONSOLE", "d-delay": d.delay && !isZero(d.delay) ? String(d.delay) : "", "d-cooldown": d.cooldown && !isZero(d.cooldown) ? String(d.cooldown) : "", + "d-target": String(!!s["target-required"]), "d-online": String(!!s["schedule-online"]), "d-player": s["player-arg"] || "" }); + edges.push([def, "output_1", cmd, P.cmd.defaults]); + (d.execute || []).forEach((t) => edges.push([server(t), "output_1", def, P.def.targets])); + } + let prev = cmd; + m.commands.forEach((c) => { + if (c.delay != null) { + const w = add("wait", { delay: String(c.delay) }); + edges.push([prev, "output_1", w, "input_1"]); + prev = w; + } + const run = add("run", { command: c.command }, { meta: { src: c._src } }); + edges.push([prev, "output_1", run, P.run.after]); + (c.execute || []).forEach((t) => edges.push([server(t), "output_1", run, P.run.targets])); + if (c["run-as"]) edges.push([add("runas", { value: c["run-as"] }), "output_1", run, P.run.runas]); + if (c.cooldown != null) edges.push([add("cooldown", { cooldown: String(c.cooldown) }), "output_1", run, P.run.cooldown]); + if (c.server) { + const o = c.server; + const rules = add("rules", { "r-target": String(!!o["target-required"]), "r-online": String(!!o["schedule-online"]), "r-player": o["player-arg"] || "" }); + edges.push([rules, "output_1", run, P.run.rules]); + } + prev = run; + }); + } + // groups are by file name since version 2 and still fit + return { nodes, edges, bases: texts, unread, groups: layout.version >= 2 && Array.isArray(layout.groups) ? layout.groups.filter(validGroup) : [] }; + }, + + // places nodes from a snapshot; nodes without a stored position get a tidy spot in their script's row + buildFrom(snap, auto = false) { + building = true; + for (const f of Object.keys(bases)) delete bases[f]; + Object.assign(bases, snap.bases); + unread = new Set(snap.unread); + df.clear(); + meta.clear(); + selected.clear(); + groups = (snap.groups || []).map((g) => ({ ...g, members: [...(g.members || [])] })); + if (auto) { + // members whose script is gone (deleted, or never saved) leave; a script's own group goes with it + for (const g of groups) g.members = g.members.filter((m) => this.scripts[m] !== undefined); + groups = groups.filter((g) => g.members.length); + } + const ids = new Map(), unplaced = new Map(); // band -> [[snapshot node, node id]] + const keep = !auto || layout.version === LAYOUT_VERSION; + for (const n of snap.nodes) { + const stored = auto ? (keep ? layout.nodes?.[n.key] : null) : n; + const has = stored && Number.isFinite(stored.x) && Number.isFinite(stored.y); + const id = this.place(n.kind, has ? stored.x : 0, has ? stored.y : 0, n.values); + ids.set(n.id, id); + if (n.meta) meta.set(id, n.meta); + if (!has) (unplaced.get(n.band) || unplaced.set(n.band, []).get(n.band)).push([n, id]); + } + for (const [from, out, to, inp] of snap.edges) if (ids.has(from) && ids.has(to)) df.addConnection(ids.get(from), ids.get(to), out, inp); + if (unplaced.size) { + // measure nodes at their final size: argument pills only appear once the wires say whose arguments they are + this.refreshDynamic(this.collect().scripts); + const fresh = new Set([...unplaced.values()].flat().map(([, id]) => id)); + const data = df.drawflow.drawflow.Home.data; + const others = Object.keys(data).filter((id) => !fresh.has(id)); + const top = others.length ? Math.max(...others.map((id) => { const r = this.rectOf(id); return r.y + r.h; })) + 160 : 0; + // a whole new script gets the full layout below everything; a new node in a placed script goes next to what it feeds + const whole = []; + for (const items of unplaced.values()) { + const cmd = items.find(([n]) => n.kind === "command"); + if (cmd) { whole.push(cmd[1]); continue; } + for (const [, id] of items) { + const to = data[id].outputs.output_1?.connections[0]?.node, r = to && this.rectOf(to); + if (r) this.settle(id, r.x - this.rectOf(id).w - 90, r.y); else this.settle(id, 0, top); + } + } + this.arrange(whole, 0, top); + } + building = false; + this.renderGroups(); + this.syncCalls(); + this.refresh(); + }, + + // lays one script out so its wires run straight into their ports without crossing nodes: + // [Defaults' servers] [aliases, arguments, servers it's registered on, Defaults] [Command] [steps...], + // and each step's own settings below the step, ending just left of it. Returns where it ends. + layoutScript(cmdId, x, y) { + cmdId = String(cmdId); + const data = df.drawflow.drawflow.Home.data, cmd = data[cmdId]; + const GAP = 90, V = 28; + // nodes shared with another script stay where that script put them + const mine = (n, port) => (n?.inputs[port]?.connections || []).map((c) => String(c.node)).filter((id) => owner.get(id) === cmdId) + .sort((a, b) => data[a].pos_y - data[b].pos_y); + const size = (id) => this.rectOf(id); + const width = (ids) => Math.max(0, ...ids.map((id) => size(id).w)); + const defId = mine(cmd, P.cmd.defaults)[0]; + const colA = [...mine(cmd, P.cmd.aliases), ...mine(cmd, P.cmd.args), ...mine(cmd, P.cmd.reg), ...(defId ? [defId] : [])]; + const colB = defId ? mine(data[defId], P.def.targets) : []; + const wA = width(colA), wB = width(colB); + const xA = x + (wB ? wB + GAP : 0), xC = xA + (wA ? wA + GAP : 0); + let bottom = y, yA = y, defY = y; + // right-aligned, so every output sits at the same distance from the command + for (const id of colA) { if (id === defId) defY = yA; this.moveNodeTo(id, xA + wA - size(id).w, yA); yA += size(id).h + V; } + let yB = defY; + for (const id of colB) { this.moveNodeTo(id, x + wB - size(id).w, yB); yB += size(id).h + V; } + this.moveNodeTo(cmdId, xC, y); + bottom = Math.max(yA - V, yB - V, y + size(cmdId).h); + // the chain of steps in one row + const chain = []; + let sx = xC + size(cmdId).w + GAP, rowBottom = y + size(cmdId).h; + for (let n = data[cmd.outputs[P.cmd.then].connections[0]?.node]; n && owner.get(String(n.id)) === cmdId && !chain.includes(String(n.id)); n = data[n.outputs.output_1.connections[0]?.node]) { + const id = String(n.id); + chain.push(id); + this.moveNodeTo(id, sx, y); + sx += size(id).w + GAP; + rowBottom = Math.max(rowBottom, y + size(id).h); + } + const bandY = rowBottom + 50; + for (const id of chain) { + if (kindOf(data[id]) !== "run") continue; + const own = [P.run.targets, P.run.runas, P.run.cooldown, P.run.rules].flatMap((port) => mine(data[id], port)); + let sy = bandY; + for (const s of own) { this.moveNodeTo(s, data[id].pos_x - 30 - size(s).w, sy); sy += size(s).h + V; } + bottom = Math.max(bottom, sy - V); + } + return { bottom: Math.max(bottom, rowBottom), right: sx - GAP }; + }, + + // scripts one below the other, group by group, with room for the frames between groups + arrange(cmdIds, x, y) { + const rank = (id) => { const i = groups.indexOf(this.groupOf(this.sidOf(id))); return i < 0 ? groups.length : i; }; + const data = df.drawflow.drawflow.Home.data; + const list = [...cmdIds].sort((a, b) => rank(a) - rank(b) || data[a].pos_y - data[b].pos_y); + let last = null; + for (const id of list) { + const g = rank(id); + if (last !== null) y += g === last ? 120 : 220; + last = g; + y = this.layoutScript(id, x, y).bottom; + } + return y; + }, + + // the Sort button: every script laid out again (undoable) + tidy() { + this.arrange(Object.values(df.drawflow.drawflow.Home.data).filter((n) => kindOf(n) === "command").map((n) => String(n.id)), 0, 0); + this.refresh(); + this.fit(); + }, + + // a group's sort button: only its scripts, where the group is now + sortGroup(g) { + this.arrange(this.groupCommands(g).map((n) => String(n.id)), g.x + 40, g.y + 84); + this.refresh(); + }, + + rebuild() { + clearTimeout(yamlTimer); + this.yamlDraft = null; + this.yamlError = ""; + if (latestScripts) this.scripts = latestScripts; + latestScripts = null; + this.externalChanges = false; + this.results = []; + const snap = this.snapshotFromScripts(); + this.loadedFiles = snap.nodes.filter((n) => n.kind === "command" && n.meta?.file).map((n) => n.meta.file); + this.buildFrom(snap, true); + history.past = []; history.future = []; history.last = this.serialize(); + this.canUndo = this.canRedo = false; + this.fit(); + }, + + /* ---------- undo / redo: snapshots of the whole graph ---------- */ + + serialize() { + const data = df.drawflow.drawflow.Home.data; + const nodes = Object.values(data).map((n) => ({ id: String(n.id), kind: kindOf(n), x: n.pos_x, y: n.pos_y, values: this.fields(n.id), meta: meta.get(String(n.id)) })); + const edges = []; + for (const n of Object.values(data)) { + for (const [out, o] of Object.entries(n.outputs)) for (const c of o.connections) edges.push([String(n.id), out, String(c.node), c.output]); + } + return JSON.stringify({ nodes, edges, groups, bases: { ...bases }, unread: [...unread] }); + }, + + // every action is its own undo step; only typing is grouped, so a word isn't one step per letter + remember(typing = false) { + if (building || restoring) return; + clearTimeout(history.timer); + if (typing) { + history.pending = true; + history.timer = setTimeout(() => this.commitHistory(), 400); + } else { + this.commitHistory(); + } + }, + + commitHistory() { + history.pending = false; + const now = this.serialize(); + if (now === history.last) return; + if (history.last) history.past.push(history.last); + if (history.past.length > 100) history.past.shift(); + history.last = now; + history.future = []; + this.canUndo = history.past.length > 0; + this.canRedo = false; + }, + + restore(snap) { + restoring = true; + const view = [df.zoom, df.canvas_x, df.canvas_y]; + this.buildFrom(JSON.parse(snap)); + this.setView(...view); + history.last = this.serialize(); // node ids change on every rebuild + restoring = false; + this.canUndo = history.past.length > 0; + this.canRedo = history.future.length > 0; + this.saveLayoutSoon(this.collect().scripts); + this.saveDraftSoon(); + }, + + undo() { + // an edit still waiting for its snapshot becomes its own undo step first; a refresh without a change + // (like leaving a field) is not one. last is always serialized fresh, so comparing text is reliable here. + if (history.pending) { clearTimeout(history.timer); this.commitHistory(); } + if (!history.past.length) return; + history.future.push(history.last); + this.restore(history.past.pop()); + }, + + redo() { + if (!history.future.length) return; + history.past.push(history.last); + this.restore(history.future.pop()); + }, + + // A step calls a script when its command starts with that script's name or alias AND it runs where + // that script is registered. Elsewhere the same word is just that server's own command (e.g. /eco). + syncCalls() { + syncingCalls = true; + const data = df.drawflow.drawflow.Home.data; + const servers = (n, port) => (n?.inputs[port]?.connections || []).map((k) => { const t = this.target(k.node); return `${t.id}@${t.location}`; }); + const byName = new Map(); + for (const n of Object.values(data)) { + if (kindOf(n) !== "command") continue; + const reg = new Set(servers(n, P.cmd.reg)); + const names = [this.fields(n.id).name, ...n.inputs[P.cmd.aliases].connections.flatMap((c) => splitList(this.fields(c.node).aliases))]; + for (const name of names) if (name) (byName.get(name.toLowerCase()) || byName.set(name.toLowerCase(), []).get(name.toLowerCase())).push({ id: String(n.id), reg }); + } + for (const cmd of Object.values(data).filter((n) => kindOf(n) === "command")) { + const defaults = servers(data[cmd.inputs[P.cmd.defaults].connections[0]?.node], P.def.targets); + const seen = new Set(); + for (let n = data[cmd.outputs.output_1.connections[0]?.node]; n && !seen.has(n.id); n = data[n.outputs.output_1.connections[0]?.node]) { + seen.add(n.id); + if (kindOf(n) !== "run") continue; + const own = servers(n, P.run.targets), where = own.length ? own : defaults; + const first = (this.fields(n.id).command || "").replace(/^\//, "").split(/\s+/)[0].toLowerCase(); + const want = (byName.get(first) || []).find((c) => c.id !== String(cmd.id) && where.some((w) => c.reg.has(w)))?.id; + const have = n.outputs[P.run.calls].connections[0]?.node; + if (have && have !== want) df.removeSingleConnection(n.id, have, P.run.calls, P.cmd.calledBy); + if (want && have !== want) df.addConnection(n.id, want, P.run.calls, P.cmd.calledBy); + } + } + syncingCalls = false; + }, + + fileOf(cmdId) { + if (!cmdId) return null; + return meta.get(cmdId)?.file || (this.fields(cmdId).name || "new") + ".yml"; + }, + + collect() { + const data = df.drawflow.drawflow.Home.data; + const nodes = Object.values(data); + const problems = [], scripts = []; + owner = new Map(); + owners = new Map(); + // a node belongs to every script that reaches it; the first one keeps its layout + const own = (id, cmd) => { + id = String(id); + (owners.get(id) || owners.set(id, new Set()).get(id)).add(cmd); + if (!owner.has(id)) owner.set(id, cmd); + }; + const from = (n, port) => (n?.inputs[port]?.connections || []).map((k) => data[k.node]).filter(Boolean).sort((a, b) => a.pos_y - b.pos_y); + const durationOk = (v) => !v || RULES.duration[0].test(v); + + const field = (node, f) => ({ node: String(node.id), sel: `[data-f="${f}"]` }); + for (const cmd of nodes.filter((n) => kindOf(n) === "command")) { + const id = String(cmd.id), f = this.fields(id), file = this.fileOf(id), info = meta.get(id) || {}; + // at: where the problem is shown, { node, sel } for a field or { node, port } for a port + const bad = (text, at) => problems.push({ file, script: f.name, text, at, blocking: true }); + const target = (n) => { own(n.id, id); return this.target(n.id); }; + own(id, id); + if (!RULES.name[0].test(f.name)) bad("Use " + RULES.name[1], field(cmd, "name")); + + const aliases = from(cmd, P.cmd.aliases).flatMap((n) => { + own(n.id, id); + const list = splitList(this.fields(n.id).aliases); + if (!list.length) bad("Add an alias, or delete this node", { node: String(n.id), sel: ".al-new" }); + for (const a of list) if (!RULES.alias[0].test(a)) bad("Use " + RULES.alias[1], { node: String(n.id), sel: `.tag[data-v="${CSS.escape(a)}"]` }); + return list; + }); + + // arguments are positional: top to bottom on the canvas + const names = new Set(); + const args = from(cmd, P.cmd.args).map((n, i) => { + own(n.id, id); + const label = document.querySelector(`#node-${n.id} .nk`), title = `Argument ${i + 1}${esc(this.fields(n.id).name)}`; + if (label && label.innerHTML !== title) label.innerHTML = title; + const a = this.fields(n.id); + if (a.name && RULES.argname[0].test(a.name) && names.has(a.name)) bad(`Another argument is already named ${a.name}`, field(n, "name")); + if (!RULES.suggestions[0].test(a.suggestions)) bad("Use " + RULES.suggestions[1], field(n, "suggestions")); + if (!a.name || !RULES.argname[0].test(a.name)) bad(a.name ? "Use " + RULES.argname[1] : "Required", field(n, "name")); + names.add(a.name); + return { name: a.name, type: a.type, required: a.required === "true", suggestions: splitList(a.suggestions), _src: meta.get(String(n.id))?.src, node: n }; + }); + let optional = false; + args.forEach((a) => { if (!a.required) optional = true; else if (optional) bad("A required argument can't come after an optional one", field(a.node, "required")); }); + args.forEach((a, i) => { if (["TEXT", "GREEDY_STRING"].includes(a.type) && i < args.length - 1) bad(`${a.type} takes the rest of the line, so it has to be the last argument`, field(a.node, "type")); }); + args.forEach((a) => delete a.node); + // a player comes from one of this script's arguments that CB can resolve to a player + const player = (name, at) => { + if (!name) return undefined; + const a = args.find((x) => x.name === name); + if (!a) bad(`/${f.name} has no argument ${name}`, at); + else if (!PLAYER_TYPES.includes(a.type)) bad(`${a.name} is ${a.type}, which can't be a player`, at); + else if (!a.required) bad(`Make ${a.name} required: the server check needs a player`, at); + return name; + }; + + const register = from(cmd, P.cmd.reg).map(target); + const defNode = from(cmd, P.cmd.defaults)[0]; + let defaults = { "run-as": "CONSOLE", execute: [], server: {} }; + if (defNode) { + own(defNode.id, id); + const d = this.fields(defNode.id); + for (const k of ["d-delay", "d-cooldown"]) if (!durationOk(d[k])) bad("Use " + RULES.duration[1], field(defNode, k)); + defaults = { + "run-as": d["d-runas"] || "CONSOLE", execute: from(defNode, P.def.targets).map(target), + delay: d["d-delay"] || undefined, cooldown: d["d-cooldown"] || undefined, + server: { "target-required": d["d-target"] === "true", "schedule-online": d["d-online"] === "true", "player-arg": player(d["d-player"], field(defNode, "d-player")) }, + }; + } else { + bad("Wire a Defaults node here", { node: id, port: "defaults" }); + } + + const commands = []; + let cur = data[cmd.outputs[P.cmd.then].connections[0]?.node], delay, nowhere = false; + const seen = new Set(); + while (cur && !seen.has(cur.id)) { + seen.add(cur.id); + own(cur.id, id); + const s = this.fields(cur.id); + if (kindOf(cur) === "wait") { + delay = s.delay; + if (!s.delay || !durationOk(s.delay)) bad(s.delay ? "Use " + RULES.duration[1] : "Required", field(cur, "delay")); + if (!cur.outputs.output_1.connections.length) bad("Nothing comes after this Wait", { node: String(cur.id), sel: ".nh" }); + } else { + if (!s.command) bad("Required", field(cur, "command")); + for (const p of new Set([...s.command.matchAll(PLACEHOLDER)].map((m) => m[1]).filter((p) => !names.has(p)))) { + bad(`/${f.name} has no argument ${p}`, { node: String(cur.id), sel: `.ph[data-arg="${CSS.escape(p)}"]` }); + } + const execute = from(cur, P.run.targets).map(target); + nowhere ||= !execute.length && !defaults.execute.length; + const ra = from(cur, P.run.runas)[0], cd = from(cur, P.run.cooldown)[0], rules = from(cur, P.run.rules)[0]; + [ra, cd, rules].forEach((v) => v && own(v.id, id)); + const r = rules && this.fields(rules.id); + if (cd && (!this.fields(cd.id).cooldown || !durationOk(this.fields(cd.id).cooldown))) bad(this.fields(cd.id).cooldown ? "Use " + RULES.duration[1] : "Required", field(cd, "cooldown")); + commands.push({ + command: s.command, "run-as": ra ? this.fields(ra.id).value : undefined, delay, cooldown: cd ? this.fields(cd.id).cooldown : undefined, execute, _src: meta.get(String(cur.id))?.src, + server: rules ? { "target-required": r["r-target"] === "true", "schedule-online": r["r-online"] === "true", "player-arg": player(r["r-player"], field(rules, "r-player")) } : undefined, + }); + delay = undefined; + } + cur = data[cur.outputs.output_1.connections[0]?.node]; + } + // the defaults' servers are where every step without its own runs + if (nowhere && defNode) bad("Wire the Server the steps run on", { node: String(defNode.id), port: "targets" }); + if (!commands.length) bad("Connect an Execute step", { node: id, port: "then" }); + if (!register.length) bad("Wire the Server this command is registered on", { node: id, port: "registered on" }); + // older schemas differ in structure; bumping the number alone would corrupt the script + if (info.version != null && String(info.version) !== "4") bad(`${file} uses schema version ${info.version}. Run /cb migrate on the proxy first.`); + + scripts.push({ + id, file, sid: this.sidOf(id), isNew: !info.file, + model: { + version: info.version ?? 4, name: f.name, description: f.description || undefined, enabled: f.enabled === "true", + aliases, permissions: { enabled: f.perm === "true", silent: f.silent === "true" }, register, defaults, args, commands, + }, + }); + } + + // anything not wired into a script would silently vanish + for (const n of nodes) { + if (!owner.has(String(n.id))) problems.push({ file: null, text: "Not connected to any script", at: { node: String(n.id), sel: ".nh" }, blocking: true }); + } + + const byName = new Map(); + for (const s of scripts) for (const name of [s.model.name, ...s.model.aliases]) byName.set(name, [...(byName.get(name) || []), s]); + for (const s of scripts) { + const err = (text, at) => problems.push({ file: s.file, script: s.model.name, text, at, blocking: true }); + for (const name of [s.model.name, ...s.model.aliases]) { + const others = [...new Set(byName.get(name).filter((o) => o !== s).map((o) => "/" + o.model.name))]; + if (!others.length) continue; + const text = `/${name} is also used by ${others.join(", ")}`; + if (name === s.model.name) err(text, { node: s.id, sel: '[data-f="name"]' }); + else err(text, { node: s.id, port: "aliases" }); + } + if (s.isNew && this.scripts[s.file] !== undefined) err(`${s.file} already exists on the proxy`, { node: s.id, sel: '[data-f="name"]' }); + } + for (const [file, text] of Object.entries({ ...this.scripts, ...bases })) { + try { conv.toModel(YAML, text); } catch (e) { problems.push({ file, text: `${file} is not valid YAML. Fix it in a text editor: ${e.message}`, blocking: false }); } + } + return { scripts, problems }; + }, + + // positions and groups for editor-layout.json, keyed so they survive reloads + layoutNow(scripts) { + const data = df.drawflow.drawflow.Home.data; + const nodes = {}; + for (const s of scripts) { + const count = {}; + for (const [nodeId, cmd] of owner) { + const n = data[nodeId]; + if (cmd !== s.id || !n) continue; + const k = kindOf(n); + const key = k === "command" ? "cmd" : k + (count[k] = (count[k] ?? -1) + 1); + nodes[`${s.file}#${key}`] = { x: Math.round(n.pos_x), y: Math.round(n.pos_y) }; + } + } + // the file stores groups by file name; unsaved scripts have none yet and drop out on the next load + const fileOfSid = new Map(scripts.map((s) => [s.sid, s.file])); + return { version: LAYOUT_VERSION, nodes, groups: groups.map((g) => ({ ...g, members: g.members.map((m) => fileOfSid.get(m) || m) })) }; + }, + + refresh(typing = false) { + if (building || !conv) return; + const { scripts, problems } = this.collect(); + this.refreshDynamic(scripts); + const out = {}; + for (const s of scripts) { + try { + // Keep the source tied to its indexes, even after the proxy saves a reordered list. + out[s.file] = conv.fromModel(YAML, bases[s.file] ?? this.scripts[s.file] ?? this.newScript(s.model.name), s.model); + } catch (e) { + problems.push({ file: s.file, text: `${s.file} could not be turned into YAML: ${e.message}`, blocking: true }); + } + } + // every script is in one group; groups of scripts that are gone go too + const sids = new Set(scripts.map((s) => s.sid)); + for (const g of groups) g.members = g.members.filter((m) => sids.has(m)); + groups = groups.filter((g) => g.members.length); + for (const s of scripts) { + if (!this.groupOf(s.sid)) groups.push(this.scriptGroup(s.sid, "/" + s.model.name)); + // a script's own group is named after it, until someone names the group + const own = groups.find((g) => g.auto && !g.named && g.members.length === 1 && g.members[0] === s.sid); + if (own) own.title = "/" + s.model.name; + } + this.fitGroups(); + this.renderGroups(); + const present = new Set([...meta.values()].map((m) => m.file).filter(Boolean)); + this.deletions = this.loadedFiles.filter((f) => !present.has(f) && !unread.has(f) && this.scripts[f] !== undefined); + this.out = out; + // only problems in scripts that would be saved, or loose nodes, block saving + const pinned = this.markProblems(problems); + this.problems = problems.map((p) => ({ ...p, pinned: pinned.has(p), blocking: p.blocking && (p.file === null || out[p.file] !== this.scripts[p.file]) })); + this.remember(typing); + this.saveLayoutSoon(scripts); + this.saveDraftSoon(); + // a change after Save makes the waiting code save something older: take it back + if (staged && JSON.stringify(this.changeSet()) !== staged) this.cancelStage(); + this.drawMinimap(); + }, + + saveLayoutSoon(scripts) { + if (restoring || this.status !== "online") return; + const next = this.layoutNow(scripts); + clearTimeout(layoutTimer); + layoutTimer = setTimeout(() => { + const text = JSON.stringify(next); + if (text === lastLayout) return; + lastLayout = text; + layout = next; + this.send({ type: "layout", layout: next }); + }, 800); + }, + + changes() { + const files = new Set(Object.keys(this.out).filter((f) => this.out[f] !== this.scripts[f])); + if (this.yamlDraft && this.yamlDraft.text !== this.out[this.yamlDraft.file]) files.add(this.yamlDraft.file); + return Math.max(files.size + this.deletions.length, this.externalChanges ? 1 : 0); + }, + + blocking() { + const problems = this.problems.filter((p) => p.blocking); + if (this.yamlDraft && this.yamlError) problems.push({ file: this.yamlDraft.file, text: this.yamlError, blocking: true }); + if (this.externalChanges) problems.push({ text: "Scripts changed on the proxy. Copy your edits before using Revert to load them.", blocking: true }); + return problems; + }, + + // the YAML panel: every script file with what saving does to it, and the chosen one, highlighted + yamlFiles() { + const files = [...new Set([...Object.keys(this.out), ...Object.keys(this.scripts)])].sort(); + const state = (f) => this.deletions.includes(f) ? "deleted" : this.scripts[f] === undefined ? "new" + : this.out[f] === undefined ? "unread" : this.out[f] !== this.scripts[f] ? "changed" : "saved"; + const order = { new: 0, changed: 1, deleted: 2, unread: 3, saved: 4 }; + return files.map((file) => ({ file, state: state(file) })).sort((a, b) => order[a.state] - order[b.state] || a.file.localeCompare(b.file)); + }, + + fileIcon() { return svg(LUCIDE.file, 14); }, + + // the YAML panel's left edge drags its width + resizeYaml(e) { + const panel = e.currentTarget.parentElement, grip = e.currentTarget, right = panel.getBoundingClientRect().right; + grip.classList.add("on"); + const move = (ev) => panel.style.setProperty("--yaml-w", Math.max(320, right - ev.clientX) + "px"); + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", () => { window.removeEventListener("mousemove", move); grip.classList.remove("on"); this.drawMinimap(); }, { once: true }); + }, + + yamlFile() { + const files = this.yamlFiles().map((f) => f.file); + return [this.yamlPick, this.selectedFile].find((f) => f && files.includes(f)) || files[0]; + }, + + yamlState() { + return this.yamlFiles().find((f) => f.file === this.yamlFile())?.state || ""; + }, + + // what the file will be after saving; a deleted file shows what goes away + yamlText() { + const f = this.yamlFile(); + if (this.yamlDraft && this.yamlDraft.file === f) return this.yamlDraft.text; + return (this.deletions.includes(f) ? this.scripts[f] : this.out[f] ?? bases[f] ?? this.scripts[f]) ?? ""; + }, + + // problems in the YAML with where they are: parse errors, wrong value types, unknown or missing keys + yamlLint(text) { + if (lint.text === text) return lint; + const marks = []; + const at = (node, msg, warn = false) => { const r = node?.range; if (r) marks.push({ from: r[0], to: Math.max(r[1], r[0] + 1), msg, warn }); }; + let doc; + try { doc = YAML.parseDocument(text); } catch (e) { doc = null; } + for (const e of doc?.errors || []) marks.push({ from: e.pos[0], to: Math.max(e.pos[1], e.pos[0] + 1), msg: e.message.split("\n")[0] }); + const args = doc && !doc.errors.length ? doc.toJS()?.args : []; + const argNames = new Set((Array.isArray(args) ? args : []).map((a) => a?.name).filter(Boolean)); + const types = window.cbArgTypes || []; + const walk = (node, sch) => { + if (!sch || node == null) return; + const scalar = YAML.isScalar(node), v = scalar ? node.value : undefined; + if (sch.type === "map") { + if (!YAML.isMap(node)) return at(node, "Should be a section of keys"); + const seen = new Set(); + for (const pair of node.items) { + const k = pair.key?.value; + seen.add(k); + if (!(k in sch.keys)) at(pair.key, `Unknown key ${k}`, true); + else walk(pair.value, sch.keys[k]); + } + for (const k of sch.required) if (!seen.has(k)) at({ range: [node.range[0], node.range[0] + 1] }, `Missing ${k}`); + } else if (sch.type === "list") { + if (!YAML.isSeq(node)) return at(node, "Should be a list"); + node.items.forEach((item) => walk(item, sch.of)); + } else if (!scalar) { + at(node, `Should be ${Y_HINTS[sch.type] === "text" ? "text" : "a single value"}`); + } else if (sch.type === "str" && typeof v !== "string") at(node, "Should be text"); + else if (sch.type === "bool" && typeof v !== "boolean") at(node, "Should be true or false"); + else if (sch.type === "int" && !Number.isInteger(v)) at(node, "Should be a whole number"); + else if (sch.type === "dur" && !RULES.duration[0].test(String(v))) at(node, "Should be " + RULES.duration[1]); + else if (sch.type === "name" && !RULES.name[0].test(String(v))) at(node, "Should be " + RULES.name[1]); + else if (sch.type === "enum" && !(sch.values || types).includes(String(v))) at(node, `Should be one of ${(sch.values || types).join(", ")}`); + else if (sch.type === "arg" && v && !argNames.has(String(v))) at(node, `No argument named ${v}`); + }; + if (doc && !doc.errors.length) { + if (!doc.contents) marks.push({ from: 0, to: 1, msg: "Should be a section of keys" }); + else walk(doc.contents, Y_SCRIPT); + } + const lines = []; + const starts = [0]; + for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1); + const lineOf = (pos) => { let l = 0; while (l + 1 < starts.length && starts[l + 1] <= pos) l++; return l; }; + for (const m of marks) (lines[lineOf(m.from)] ||= []).push(m); + lint = { text, marks, lines }; + return lint; + }, + + // the same text, see-through, with the problems underlined; it lies under the typing field + yamlLintHtml() { + const text = this.yamlText(), { marks } = this.yamlLint(text); + let html = "", pos = 0; + for (const m of [...marks].sort((a, b) => a.from - b.from)) { + if (m.from < pos) continue; + html += esc(text.slice(pos, m.from)) + `${esc(text.slice(m.from, m.to)) || " "}`; + pos = m.to; + } + return html + esc(text.slice(pos)) + "\n"; + }, + + // what the caret's line has wrong, shown above the code + yamlLineMsg() { + const ms = this.yamlLint(this.yamlText()).lines[this.yamlLine]; + return ms ? ms.map((m) => m.msg).join("; ") : ""; + }, + + yamlCaret(t) { + this.yamlLine = t.value.slice(0, t.selectionStart).split("\n").length - 1; + }, + + // the schema for a line, from the keys it sits under; list items count as one step down + yamlSchemaAt(lines, li, col) { + const keyCol = (l) => { const m = l.match(/^(\s*)(- )?([\w-]+):/); return m ? m[1].length + (m[2] ? 2 : 0) : -1; }; + const path = []; + let c = col; + for (let i = li - 1; i >= 0 && c > 0; i--) { + const kc = keyCol(lines[i]); + if (kc >= 0 && kc < c) { path.unshift(lines[i].match(/([\w-]+):/)[1]); c = kc; } + } + let sch = Y_SCRIPT; + for (const k of path) { + if (sch?.type === "list") sch = sch.of; + sch = sch?.type === "map" ? sch.keys[k] : null; + } + if (sch?.type === "list") sch = sch.of; + return sch; + }, + + // completion at the caret, like nvim-cmp: keys where a key goes, values after a key + yamlComplete(t, force = false) { + const text = t.value, caret = t.selectionStart; + const lineStart = text.lastIndexOf("\n", caret - 1) + 1, before = text.slice(lineStart, caret); + const lines = text.split("\n"), li = text.slice(0, caret).split("\n").length - 1; + let items = [], from = caret; + const val = before.match(/^(\s*)(- )?([\w-]+):\s*([^\s#]*)$/); + const key = before.match(/^(\s*)(- )?([\w-]*)$/); + if (val) { + const sch = this.yamlSchemaAt(lines, li, val[1].length + (val[2] ? 2 : 0)); + const s2 = sch?.type === "map" ? sch.keys[val[3]] : null, typed = val[4]; + from = caret - typed.length; + const opts = !s2 ? [] : s2.type === "bool" ? ["true", "false"] : s2.type === "enum" ? s2.values || window.cbArgTypes || [] + : s2.type === "dur" ? ["0s", "500ms", "1s", "5s", "10s", "30s", "1m", "5m", "1h", "24h"] : s2.type === "server" ? [...this.knownServers().keys()].map((id) => `"${id}"`) + : s2.type === "arg" ? (conv && (() => { try { return conv.toModel(YAML, text).args.map((a) => a.name); } catch { return []; } })()) || [] : []; + if (!before.endsWith(":") || force) items = opts.filter((o) => o.toLowerCase().startsWith(typed.toLowerCase()) && o !== typed).map((o) => ({ label: o, kind: Y_HINTS[s2.type] })); + } else if (key && (key[3] || force)) { + const col = key[1].length + (key[2] ? 2 : 0); + const sch = this.yamlSchemaAt(lines, li, col); + if (sch?.type === "map") { + // keys already in this block (this list item) aren't offered again + const kc = (l) => { const m = l.match(/^(\s*)(- )?([\w-]+):/); return m ? [m[1].length + (m[2] ? 2 : 0), m[3], !!m[2]] : null; }; + const have = new Set(); + for (let i = li - 1; i >= 0; i--) { const k = kc(lines[i]); if (!k) continue; if (k[0] < col) break; if (k[0] === col) { have.add(k[1]); if (k[2]) break; } } + for (let i = li + 1; i < lines.length; i++) { const k = kc(lines[i]); if (!k) continue; if (k[0] < col || (k[0] === col && k[2])) break; if (k[0] === col) have.add(k[1]); } + from = caret - key[3].length; + items = Object.entries(sch.keys).filter(([k]) => !have.has(k) && k.startsWith(key[3]) && k !== key[3]).map(([k, v]) => ({ label: k, kind: Y_HINTS[v.type] || "key", key: true, block: ["map", "list"].includes(v.type) })); + } + } + if (!items.length) { this.yamlSugg = null; return; } + // where the caret is, from the monospace cell size + const cell = this.yamlCell(t), col = caret - lineStart; + this.yamlSugg = { items: items.slice(0, 50), sel: 0, from, to: caret, x: 16 + (from - lineStart) * cell.w - t.scrollLeft, y: 12 + (li + 1) * cell.h }; + }, + + yamlCell(t) { + if (!yamlCellSize) { + const probe = document.createElement("span"); + probe.textContent = "M".repeat(10); + probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;font:inherit"; + t.parentElement.querySelector(".ed-yaml-code").append(probe); + yamlCellSize = { w: probe.getBoundingClientRect().width / 10, h: parseFloat(getComputedStyle(t).lineHeight) }; + probe.remove(); + } + return yamlCellSize; + }, + + yamlAccept(i) { + const sg = this.yamlSugg, t = document.querySelector(".ed-yaml-ta"), item = sg?.items[i ?? sg.sel]; + if (!item) return; + const indent = (t.value.slice(0, sg.from).match(/(^|\n)(\s*)(- )?[^\n]*$/)?.[2] || "") + (t.value.slice(0, sg.from).match(/(^|\n)\s*- [^\n]*$/) ? " " : ""); + const insert = item.key ? (item.block ? `${item.label}:\n${indent} ` : `${item.label}: `) : item.label; + t.focus(); + t.setRangeText(insert, sg.from, sg.to, "end"); + this.yamlSugg = null; + this.yamlInput({ target: t }); + if (item.key && !item.block) this.yamlComplete(t, true); + }, + + // keys while typing YAML: the completion list takes arrows, Tab, Enter and Escape when it's open + yamlKey(e) { + const sg = this.yamlSugg, t = e.target; + if (sg) { + const n = sg.items.length; + if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n") || (e.key === "Tab" && !e.shiftKey)) { e.preventDefault(); sg.sel = (sg.sel + 1) % n; return; } + if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p") || (e.key === "Tab" && e.shiftKey)) { e.preventDefault(); sg.sel = (sg.sel - 1 + n) % n; return; } + if (e.key === "Enter") { e.preventDefault(); this.yamlAccept(); return; } + if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); this.yamlSugg = null; return; } + } + if (e.ctrlKey && e.key === " ") { e.preventDefault(); this.yamlComplete(t, true); return; } + if (e.key === "Tab") { e.preventDefault(); this.yamlTab(e); return; } + if (e.key === "Escape") t.blur(); + }, + + yamlEditable() { + return ["new", "changed", "saved"].includes(this.yamlState()); + }, + + yamlInput(e) { + this.yamlError = ""; // the underlines show what's wrong while typing + this.yamlDraft = { file: this.yamlFile(), text: e.target.value }; + if (staged || this.confirmCode) this.cancelStage(); + clearTimeout(yamlTimer); + yamlTimer = setTimeout(() => this.yamlApply(), 700); + }, + + // edited YAML that parses becomes the graph: the script's nodes are rebuilt, everything keeps its place + yamlApply() { + clearTimeout(yamlTimer); + const draft = this.yamlDraft; + if (!draft) return; + try { + conv.toModel(YAML, draft.text); + const error = this.yamlLint(draft.text).marks.find((m) => !m.warn); + if (error) throw new Error(error.msg); + } catch (e) { this.yamlError = e.message.split("\n")[0]; return; } + this.yamlError = ""; + if (draft.text === this.out[draft.file]) { this.yamlDraft = null; return; } + const texts = { ...this.scripts, ...this.out, [draft.file]: draft.text }; + for (const f of this.deletions) delete texts[f]; + layout = this.layoutNow(this.collect().scripts); + const view = [df.zoom, df.canvas_x, df.canvas_y]; + this.buildFrom(this.snapshotFromScripts(texts), true); + this.setView(...view); + this.yamlDraft = null; + }, + + // Tab indents by two spaces in the YAML, like an editor + yamlTab(e) { + const t = e.target, a = t.selectionStart; + t.setRangeText(" ", a, t.selectionEnd, "end"); + this.yamlInput(e); + }, + + // highlighting in the editor's own colors: keys, strings, numbers, switches, enums, comments and ${arguments} + yamlHtml() { + const value = (v) => { + const m = v.match(/^(\s*)(.*?)(\s+#.*)?$/); + let body = m[2]; + if (/^(true|false|null)$/.test(body)) body = `${body}`; + else if (/^-?\d+(\.\d+)?(ms|s|m|h)?$/.test(body)) body = `${body}`; + else if (/^[A-Z][A-Z0-9_]*$/.test(body)) body = `${body}`; + else if (/^\[.*\]$/.test(body)) body = `${esc(body)}`; + else if (body) body = `${esc(body).replace(/\$\{([^}]*)\}/g, '$1')}`; + return m[1] + body + (m[3] ? `${esc(m[3])}` : ""); + }; + return this.yamlText().split("\n").map((line) => { + if (/^\s*#/.test(line)) return `${esc(line)}`; + const m = line.match(/^(\s*)(- )?([A-Za-z0-9_-]+)(:)(.*)$/); + if (m) return `${m[1]}${m[2] ? '- ' : ""}${m[3]}:${value(m[5])}`; + const d = line.match(/^(\s*)- (.*)$/); + return d ? `${d[1]}- ${value(" " + d[2]).slice(1)}` : esc(line); + }).join("\n"); + }, + + yamlGutter() { + const f = this.yamlFile(), state = this.yamlState(); + const saved = new Set((this.scripts[f] ?? "").split("\n")); + const { lines } = this.yamlLint(this.yamlText()); + return this.yamlText().split("\n").map((l, i) => { + const ms = lines[i], cls = [state === "new" ? "add" : state === "deleted" ? "del" : state === "changed" && !saved.has(l) ? "chg" : "", ms ? (ms.every((m) => m.warn) ? "lw" : "le") : ""].join(" "); + return `${i + 1}`; + }).join("\n"); + }, + + newScript(name) { + return `version: 4\n\nname: ${name}\nenabled: true\n\npermissions:\n enabled: true\n silent: false\n\nregister: []\n\ndefaults:\n run-as: CONSOLE\n\nargs: []\n\ncommands: []\n`; + }, + + // a new script gets its own row below everything else, one step, and is registered on the proxy + addScript(at) { + const taken = new Set(Object.values(df.drawflow.drawflow.Home.data).filter((n) => kindOf(n) === "command").map((n) => this.fields(n.id).name)); + let name = "new-command", i = 2; + while (taken.has(name) || this.scripts[name + ".yml"] !== undefined) name = "new-command-" + i++; + const b = this.bounds(); + const x = at ? at.x : 0, y = at ? at.y : b ? b.y1 + 220 : 0; + const proxyId = Object.keys(this.servers).find((id) => this.servers[id].location === "VELOCITY"); + const cmd = this.place("command", x, y, { name }); + df.addConnection(cmd, this.place("run", x, y, { command: "say hello" }), "output_1", P.run.after); + const def = this.place("defaults", x, y, {}); + df.addConnection(def, cmd, "output_1", P.cmd.defaults); + // registered on the proxy and running there, until changed + if (proxyId) { + df.addConnection(this.place("server", x, y, { id: proxyId, location: "VELOCITY" }), cmd, "output_1", P.cmd.reg); + df.addConnection(this.place("server", x, y, { id: proxyId, location: "VELOCITY" }), def, "output_1", P.def.targets); + } + this.collect(); // who owns the new nodes, for the layout + const end = this.layoutScript(cmd, x, y); + if (!at) this.centerOn(x + (end.right - x) / 2, y + (end.bottom - y) / 2); + selected.clear(); selected.add(cmd); this.paintSelection(); + document.querySelector(`#node-${cmd} [data-f="name"]`).select(); + this.refresh(); + }, + + /* ================= save ================= */ + + // what saving would write: changed and new files, and the files to delete + changeSet() { + const files = {}; + for (const f of Object.keys(this.out)) if (this.out[f] !== this.scripts[f]) files[f] = this.out[f]; + return { files, deletions: [...this.deletions] }; + }, + + // Save stages the changes on the proxy; they are written once someone runs the confirm command there + save() { + if (this.yamlDraft) this.yamlApply(); + if (this.busy || this.confirmCode) return; + this.refresh(); + if (this.yamlDraft || this.blocking().length || !this.changes()) return; + this.results = []; + this.busy = true; + const set = this.changeSet(); + staged = JSON.stringify(set); + this.send({ type: "stage", ...set }); + }, + + cancelStage() { + staged = null; + this.confirmCode = null; + this.send({ type: "unstage" }); + }, + + copyConfirm() { + navigator.clipboard?.writeText(`/cb editor confirm ${this.confirmCode}`); + this.copied = true; + setTimeout(() => { this.copied = false; }, 1500); + }, + + // unsaved work is kept on the proxy, so closing the tab or a new link doesn't lose it + saveDraftSoon() { + if (restoring || this.status !== "online") return; + clearTimeout(draftTimer); + draftTimer = setTimeout(() => { + const set = this.changeSet(); + const text = JSON.stringify(set); + if (text === lastDraft) return; + lastDraft = text; + this.send({ type: "draft", draft: Object.keys(set.files).length || set.deletions.length ? { ...set, at: Date.now() } : {} }); + }, 1500); + }, + + // a draft from earlier is laid over the saved scripts, the same way an edit in the YAML panel is + restoreDraft(d) { + const texts = { ...this.scripts, ...d.files }; + for (const f of d.deletions || []) delete texts[f]; + this.buildFrom(this.snapshotFromScripts(texts), true); + history.past = []; history.future = []; history.last = this.serialize(); + this.canUndo = this.canRedo = false; + this.fit(); + this.results = [{ ok: true, warning: true, restored: true, text: `Restored the unsaved changes from ${new Date(d.at || Date.now()).toLocaleString()}.` }]; + }, + + // drops an offered draft without looking at it, here and on the proxy + dropDraft() { + this.results = this.results.filter((r) => !r.offer); + lastDraft = "{}"; + this.send({ type: "draft", draft: {} }); + }, + + discardDraft() { + this.results = []; + this.rebuild(); + }, + + /* ================= connection ================= */ + + send(msg) { + this.relay.send(msg); + }, + + receive(msg) { + if (msg.type === "state") { + const first = this.status !== "online"; + // Only the exact acknowledged write is ours, not another edit to the same file. + const previous = latestScripts || this.scripts; + const external = Object.keys({ ...previous, ...msg.scripts }).some((f) => previous[f] !== msg.scripts[f] && (!applied.has(f) || applied.get(f) !== msg.scripts[f])); + const hadChanges = this.changes() > 0; + if (!first && hadChanges && (external || this.externalChanges)) { + latestScripts = msg.scripts; + this.externalChanges = true; + if (staged || this.confirmCode) this.cancelStage(); + } else this.scripts = msg.scripts; + this.servers = msg.servers || {}; + this.confirmCode = !first && staged && CONFIRM_CODE.test(msg.pending || "") ? msg.pending : null; + // A reloaded page has not reviewed the pending set, even if it offers the stored draft. + if (first && msg.pending) this.cancelStage(); + if (first) { layout = msg.layout || layout; lastLayout = JSON.stringify(layout); } + this.status = "online"; + if (first) { + this.rebuild(); + // keeps the session alive while the tab is open; the proxy closes it a few minutes after the tab is gone + setInterval(() => this.send({ type: "ping" }), 60_000); + const d = msg.draft; + // a draft is offered, not applied: it could have been left by anyone who had an editor link + if (d?.files && (Object.keys(d.files).some((f) => d.files[f] !== msg.scripts[f]) || d.deletions?.length)) + this.results.push({ ok: true, warning: true, offer: d, text: `There are unsaved changes from ${new Date(d.at || Date.now()).toLocaleString()}. Review them before you save.` }); + lastDraft = JSON.stringify(this.changeSet()); + } else if (external && !hadChanges) { + this.rebuild(); + } else { + if (!this.externalChanges) { + const savedFiles = [...applied.keys()].filter((f) => msg.scripts[f] !== undefined); + this.loadedFiles = [...new Set([...this.loadedFiles, ...savedFiles])].filter((f) => msg.scripts[f] !== undefined); + } + if (external) this.results.push({ ok: true, warning: true, text: "Scripts changed on the proxy. Copy your edits before using Revert to load them. Saving is blocked until then." }); + this.refresh(); + } + applied.clear(); + } else if (msg.type === "staged") { + this.busy = false; + if (!staged || this.yamlDraft || this.externalChanges || JSON.stringify(this.changeSet()) !== staged) { this.cancelStage(); return; } + const bad = Object.entries(msg.errors || {}); + if (bad.length) { + staged = null; + for (const [file, errs] of bad) this.results.push({ ok: false, text: `${file} can't be saved: ${errs.join("; ")}` }); + } else { + if (CONFIRM_CODE.test(msg.code || "")) this.confirmCode = msg.code; + else { + this.cancelStage(); + this.results.push({ ok: false, text: "The proxy sent an invalid confirmation code. Save again or run /cb editor for a new link." }); + } + } + } else if (msg.type === "applied") { + const set = staged && JSON.parse(staged); + staged = null; + this.confirmCode = null; + for (const [file, errs] of Object.entries(msg.results)) { + if (!errs.length) { + if (set) applied.set(file, set.files[file]); + // a new script now has a file; remember it so it isn't treated as new again + for (const [id, cmd] of owner) { + if (id === cmd && !meta.get(id)?.file && this.fileOf(id) === file) meta.set(id, { ...meta.get(id), file, version: 4 }); + } + } + this.results.push({ ok: !errs.length, text: errs.length ? `${file}: not saved: ${errs.join("; ")}` : `${file}: ${this.deletions.includes(file) ? "deleted" : "saved"}` }); + } + if (msg.warning) this.results.push({ ok: true, warning: true, text: msg.warning }); + } else if (msg.type === "layout-error") { + this.results.push({ ok: false, text: "Layout not stored: " + msg.error }); + } + }, + }; +} diff --git a/src/lib/editor/logos/fabric.png b/src/lib/editor/logos/fabric.png new file mode 100644 index 0000000..12c4531 Binary files /dev/null and b/src/lib/editor/logos/fabric.png differ diff --git a/src/lib/editor/logos/folia.svg b/src/lib/editor/logos/folia.svg new file mode 100644 index 0000000..f426418 --- /dev/null +++ b/src/lib/editor/logos/folia.svg @@ -0,0 +1,37 @@ + + + + + + diff --git a/src/lib/editor/logos/forge.svg b/src/lib/editor/logos/forge.svg new file mode 100644 index 0000000..ae77c10 --- /dev/null +++ b/src/lib/editor/logos/forge.svg @@ -0,0 +1 @@ + diff --git a/src/lib/editor/logos/paper.svg b/src/lib/editor/logos/paper.svg new file mode 100644 index 0000000..0b9a513 --- /dev/null +++ b/src/lib/editor/logos/paper.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/editor/logos/spigot.svg b/src/lib/editor/logos/spigot.svg new file mode 100644 index 0000000..05c14a6 --- /dev/null +++ b/src/lib/editor/logos/spigot.svg @@ -0,0 +1 @@ +SpigotMC \ No newline at end of file diff --git a/src/lib/editor/logos/velocity.svg b/src/lib/editor/logos/velocity.svg new file mode 100644 index 0000000..4ea0c13 --- /dev/null +++ b/src/lib/editor/logos/velocity.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/lib/editor/relay.js b/src/lib/editor/relay.js new file mode 100644 index 0000000..b39979c --- /dev/null +++ b/src/lib/editor/relay.js @@ -0,0 +1,94 @@ +// The page's end of a proxy session on cb.objz.dev. The link's #id:secret names the relay channel, and the secret +// is the key every message is encrypted with (AES-GCM, see RelayCrypto.java), so the relay can neither read nor +// change what passes through it. Used by the editor and the setup page. + +// a new link in the same tab only changes the #fragment, which doesn't reload the page; +// without this the page would keep talking to the old, ended session +window.addEventListener("hashchange", () => location.reload()); + +// The link for this tab. The secret is taken out of the address bar and history right away and kept for this +// tab only, so a reload still works but the link doesn't linger in history or sync. +window.cbLink = () => { + const slot = "cb-link:" + location.pathname; + if (location.hash.length > 1) { + const hash = location.hash; + try { sessionStorage.setItem(slot, hash); } catch {} + history.replaceState(null, "", location.pathname + location.search); + return hash; + } + try { return sessionStorage.getItem(slot) || ""; } catch { return ""; } +}; + +// Use fixed close messages because the relay's reason text is not authenticated. +const CLOSED = { + 1000: "the proxy closed it", + 4001: "it was opened in another tab", + 4404: "this link has ended", + 1006: "the relay could not be reached", +}; + +window.cbRelay = (hash) => { + const [id, secret] = String(hash).replace(/^#/, "").split(":"); + if (!/^[0-9a-f]{32}$/.test(id || "") || !/^[A-Za-z0-9_-]{43}$/.test(secret || "")) return null; + const base = location.hostname === "localhost" ? "ws://localhost:8787/api/editor/" : "wss://cb.objz.dev/api/editor/"; + const b64url = (bytes) => { + const data = new Uint8Array(bytes); + let binary = ""; + for (let i = 0; i < data.length; i += 32768) binary += String.fromCharCode(...data.subarray(i, i + 32768)); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + }; + const unb64url = (t) => Uint8Array.from(atob(t.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); + const enc = (t) => new TextEncoder().encode(t); + // replies are bound to this page load, so old proxy messages can't be replayed into it + const conn = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join(""); + let ws, sendSeq = 0, recvSeq = 0; + // WebCrypto is async; chain sends and receives so sequence numbers stay in order + let queue = Promise.resolve(); + const enqueue = (fn) => { queue = queue.then(fn).catch((e) => console.error(e)); }; + const keyP = crypto.subtle.importKey("raw", unb64url(secret), "HKDF", false, ["deriveKey"]) + .then((ikm) => crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: enc("commandbridge-relay-v1"), info: enc("aes-256-gcm") }, + ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"])); + // the sender's role, this page's connection and the sequence number are bound into every message + const aad = (role, seq) => enc(`${role}\n${conn}\n${seq}`); + + const relay = { + onOpen: () => {}, + onMessage: () => {}, + onClose: () => {}, + send(msg) { + enqueue(async () => { + const seq = ++sendSeq; + const iv = crypto.getRandomValues(new Uint8Array(12)); + const box = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad("editor", seq) }, await keyP, enc(JSON.stringify(msg))); + ws.send(JSON.stringify({ conn, seq, iv: b64url(iv), box: b64url(box) })); + }); + }, + connect() { + ws = new WebSocket(base + id + "/ws?role=editor"); + ws.onopen = () => relay.onOpen(); + ws.onmessage = (e) => { + if (typeof e.data !== "string" || e.data.length > 4 * 1024 * 1024) { + ws.close(1009, "Message too large or not text"); + return; + } + enqueue(async () => { + let body; + try { + const { conn: c, seq, iv, box } = JSON.parse(e.data); + if (c !== conn || !Number.isSafeInteger(seq) || seq <= recvSeq || typeof iv !== "string" || typeof box !== "string") throw new Error("not for this page"); + body = new TextDecoder().decode(await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64url(iv), additionalData: aad("proxy", seq) }, await keyP, unb64url(box))); + recvSeq = seq; + } catch { + console.warn("Dropped a message that could not be opened or was already seen"); + return; + } + await relay.onMessage(JSON.parse(body)); + }); + }; + ws.onclose = (e) => relay.onClose(CLOSED[e.code] || "the connection closed"); + return relay; + }, + }; + return relay; +}; diff --git a/src/lib/editor/script-yaml.js b/src/lib/editor/script-yaml.js new file mode 100644 index 0000000..ee73915 --- /dev/null +++ b/src/lib/editor/script-yaml.js @@ -0,0 +1,248 @@ +// Converts CommandBridge script YAML to a plain model for the node editor and back. +// Saving patches the original document instead of re-serializing it, so comments, +// formatting and keys the editor doesn't know about survive an edit. +// `YAML` is the `yaml` package (eemeli/yaml v2), passed in so this runs in the browser and in node. + +export const ARG_TYPES = ["STRING", "INTEGER", "BOOLEAN", "DOUBLE", "TEXT", "GREEDY_STRING", "RANGE", + "PLAYERS", "OFFLINE_PLAYER", "ENTITIES", "ENTITY_TYPE", "WORLD", "SERVER", "LOCATION", "LOCATION_2D", + "ANGLE", "ROTATION", "ITEM_STACK", "ENCHANTMENT", "POTION_EFFECT", "SOUND", "BIOME", "TIME"]; +export const RUN_AS = ["CONSOLE", "PLAYER", "OPERATOR"]; +export const LOCATIONS = ["VELOCITY", "BACKEND"]; + +/** Parses script text into the editor model; list items remember their index in the file as `_src`. */ +export function toModel(YAML, text) { + const doc = YAML.parseDocument(text); + if (doc.errors.length) throw new Error(doc.errors[0].message); + const js = doc.toJS() || {}; + const tag = (list) => (Array.isArray(list) ? list : []).map((item, i) => ({ ...item, _src: i })); + const defaults = js.defaults ?? {}; + return { + version: js.version ?? 4, + name: js.name ?? "", + description: js.description, + enabled: js.enabled ?? true, + aliases: js.aliases ?? [], + // same defaults as the Java model: permission checks are on unless disabled + permissions: { enabled: js.permissions?.enabled ?? true, silent: js.permissions?.silent ?? false }, + register: tag(js.register), + defaults: { ...defaults, execute: defaults.execute && tag(defaults.execute) }, + args: tag(js.args).map((a) => ({ ...a, required: a.required ?? false, type: a.type ?? "STRING" })), + commands: tag(js.commands).map((c) => ({ ...c, execute: c.execute && tag(c.execute) })), + }; +} + +/** Writes the model back into `original` (or a fresh document when null) and returns the new text. */ +export function fromModel(YAML, original, model) { + const doc = original ? YAML.parseDocument(original) : new YAML.Document({}); + if (!YAML.isMap(doc.contents)) doc.contents = doc.createNode({}); + const root = doc.contents; + + setField(YAML, doc, root, "version", model.version ?? 4); + setField(YAML, doc, root, "name", model.name); + setField(YAML, doc, root, "description", model.description || undefined); + setField(YAML, doc, root, "enabled", model.enabled, true); + setField(YAML, doc, root, "aliases", model.aliases, []); + + // a block the file didn't have is written out in full, like a new item + const newPerms = !root.has("permissions"); + const perms = childMap(doc, root, "permissions"); + setField(YAML, doc, perms, "enabled", model.permissions.enabled, newPerms ? undefined : true); + setField(YAML, doc, perms, "silent", model.permissions.silent, newPerms ? undefined : false); + + syncTargets(YAML, doc, root, "register", model.register, true); + + const d = model.defaults; + const newDefaults = !root.has("defaults"); + const defaults = childMap(doc, root, "defaults"); + setField(YAML, doc, defaults, "run-as", d["run-as"], newDefaults ? undefined : "CONSOLE"); + syncTargets(YAML, doc, defaults, "execute", d.execute); + const s = d.server || {}; + if (defaults.has("server") || s["target-required"] || s["schedule-online"] || s["player-arg"]) { + patchServer(YAML, doc, childMap(doc, defaults, "server"), s); + } + setDefaultDuration(YAML, doc, defaults, "delay", d.delay); + setDefaultDuration(YAML, doc, defaults, "cooldown", d.cooldown); + + // new items spell out every field; existing ones keep omitted defaults omitted + syncItems(YAML, doc, root, "args", model.args, (node, a, fresh) => { + setField(YAML, doc, node, "name", a.name); + setField(YAML, doc, node, "required", a.required, fresh ? undefined : false); + setField(YAML, doc, node, "type", a.type, fresh ? undefined : "STRING"); + setField(YAML, doc, node, "suggestions", a.suggestions, fresh ? undefined : []); + }); + + // per-command keys left undefined inherit from defaults, so they are removed + syncItems(YAML, doc, root, "commands", model.commands, (node, c) => { + setField(YAML, doc, node, "command", c.command); + setField(YAML, doc, node, "run-as", c["run-as"] || undefined); + syncTargets(YAML, doc, node, "execute", c.execute); + setField(YAML, doc, node, "delay", c.delay || undefined); + setField(YAML, doc, node, "cooldown", c.cooldown || undefined); + // A step's server block replaces the defaults' block as a whole (CB merges top-level fields only), + // so keys missing inside it mean false/none. Rewrite it only when its effective values change. + const have = node.has("server") ? effectiveServer(plain(YAML, node.get("server", true))) : null; + const want = c.server ? effectiveServer(c.server) : null; + if (!want) { + if (have) node.delete("server"); + } else if (!have || !same(have, want)) { + const srv = childMap(doc, node, "server"); + setField(YAML, doc, srv, "target-required", !!c.server["target-required"]); + setField(YAML, doc, srv, "schedule-online", !!c.server["schedule-online"]); + setField(YAML, doc, srv, "player-arg", c.server["player-arg"] || undefined); + } + }); + + return keepFormatting(YAML, original, doc.toString({ flowCollectionPadding: false })); +} + +// The yaml library re-renders the whole file, normalizing indentation and spacing. Lines that only +// differ in whitespace get their original text back, unless that changes what the file means. +function keepFormatting(YAML, original, generated) { + if (!original) return generated; + const key = (l) => l.trim().replace(/\s+#/, " #"); + const a = original.split("\n"), b = generated.split("\n"); + // longest common subsequence of lines, compared without whitespace differences + const dp = Array.from({ length: a.length + 1 }, () => new Uint16Array(b.length + 1)); + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + dp[i][j] = key(a[i]) === key(b[j]) ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const out = []; + let i = 0, j = 0; + while (j < b.length) { + if (i < a.length && key(a[i]) === key(b[j])) { out.push(a[i]); i++; j++; } + else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) { + if (!a[i].trim()) out.push(a[i]); // the library collapses blank lines + i++; + } + else { out.push(b[j]); j++; } + } + const merged = out.join("\n"); + try { + const same = JSON.stringify(YAML.parse(merged)) === JSON.stringify(YAML.parse(generated)); + return same ? merged : generated; + } catch { + return generated; + } +} + +function patchServer(YAML, doc, map, server) { + setField(YAML, doc, map, "target-required", server["target-required"], false); + setField(YAML, doc, map, "schedule-online", server["schedule-online"], false); + setField(YAML, doc, map, "player-arg", server["player-arg"] || undefined); +} + +// a default of 0s means the same as no default, so `delay: 0s` stays when the editor has none +const isZero = (v) => v != null && /^\s*0+(\.0+)?\s*(ms|s|m|h)?\s*$/i.test(String(v)); +function setDefaultDuration(YAML, doc, map, key, value) { + if (!value && isZero(plain(YAML, map.get(key, true)))) return; + setField(YAML, doc, map, key, value || undefined); +} + +const effectiveServer = (o) => ({ t: String(!!o?.["target-required"]), s: String(!!o?.["schedule-online"]), p: String(o?.["player-arg"] || "") }); +const plain = (YAML, node) => (YAML.isNode(node) ? node.toJSON() : node); + +function childMap(doc, parent, key) { + let node = parent.get(key, true); + if (!node || typeof node.get !== "function") { + const existed = parent.has(key); + node = doc.createNode({}); + parent.set(key, node); + if (!existed) moveToSchemaPosition(parent, key); + } + return node; +} + +// "10" from a text field equals 10 in the file, and "" equals a missing value +const same = (a, b) => { + if ((a === "" || a === null) && b === undefined) return true; + if (a !== null && b !== null && typeof a !== "object" && typeof b !== "object") return String(a) === String(b); + return JSON.stringify(a) === JSON.stringify(b); +}; + +// Only touches the file when the value really changed; an absent key equal to `def` stays absent. +function setField(YAML, doc, map, key, value, def) { + const has = map.has(key); + if (value === undefined) { + if (has && !same(plain(YAML, map.get(key, true)), undefined)) map.delete(key); + return; + } + if (has) { + const current = map.get(key, true); + if (same(plain(YAML, current), value)) return; + } else if (def !== undefined && same(value, def)) { + return; + } + const scalars = Array.isArray(value) && value.every((v) => v === null || typeof v !== "object"); + const current = has ? map.get(key, true) : undefined; + if (value === null || typeof value !== "object") { + map.set(key, value); // keeps the existing scalar node, and its comments, when there is one + } else if (scalars && YAML.isSeq(current)) { + current.items = value.map((v) => doc.createNode(v)); // keeps the list node and its comment + } else { + const node = doc.createNode(value); + if (scalars && value.length) node.flow = true; + map.set(key, node); + } + if (!has) moveToSchemaPosition(map, key); +} + +// The documented key order; a key the file didn't have goes before the first later key, not at the end +const KEY_ORDER = ["version", "name", "description", "enabled", "silent", "aliases", "permissions", "register", + "defaults", "args", "commands", "command", "required", "type", "suggestions", "id", "location", "run-as", + "execute", "server", "target-required", "schedule-online", "player-arg", "delay", "cooldown"]; + +function moveToSchemaPosition(map, key) { + const rank = KEY_ORDER.indexOf(key); + if (rank < 0) return; + const items = map.items; + const pair = items.pop(); + const at = items.findIndex((p) => KEY_ORDER.indexOf(String(p.key?.value ?? p.key)) > rank); + items.splice(at < 0 ? items.length : at, 0, pair); +} + +// Target lists ({id, location}); an empty optional list is removed so it inherits again +function syncTargets(YAML, doc, parent, key, targets, required = false) { + const list = targets || []; + const current = plain(YAML, parent.get(key, true)); + if (same(norm(current), norm(list))) return; + if (!list.length && !required) { + parent.delete(key); + return; + } + // shared server nodes don't know their line in the file, so match entries by id and location + const original = norm(current), used = new Set(); + const matched = list.map((t) => { + if (t._src != null) return t; + const i = original.findIndex((o, k) => !used.has(k) && o.id === String(t.id) && o.location === String(t.location)); + if (i >= 0) used.add(i); + return { ...t, _src: i >= 0 ? i : undefined }; + }); + syncItems(YAML, doc, parent, key, matched, (node, t) => { + setField(YAML, doc, node, "id", t.id); + setField(YAML, doc, node, "location", t.location); + }); +} + +const norm = (list) => (Array.isArray(list) ? list.map((t) => ({ id: String(t?.id ?? ""), location: String(t?.location ?? "") })) : []); + +// Rebuilds a list from model items, reusing each item's original node so its comments move with it. +function syncItems(YAML, doc, parent, key, items, patch) { + const seq = parent.get(key, true); + const old = YAML.isSeq(seq) ? seq.items : []; + const next = items.map((item) => { + const reuse = item._src != null && YAML.isMap(old[item._src]); + const node = reuse ? old[item._src] : doc.createNode({}); + patch(node, item, !reuse); + return node; + }); + if (YAML.isSeq(seq)) { + seq.items = next; + if (next.length && seq.flow) seq.flow = false; // `args: []` grows into a normal block list + } else { + const fresh = doc.createNode([]); + fresh.items = next; + parent.set(key, fresh); + } +} diff --git a/src/lib/setup/setup.css b/src/lib/setup/setup.css new file mode 100644 index 0000000..609116d --- /dev/null +++ b/src/lib/setup/setup.css @@ -0,0 +1,138 @@ +/* Network setup: one question at a time in a centered column, in the homepage's type. + Monochrome, site tokens only; red is kept for errors. */ + +.su { --su-header: 45px; --bad: #ef4444; font-size: 1rem; color: hsl(var(--foreground)); } +@media (min-width: 640px) { .su { --su-header: 49px; } } +@media (min-width: 1024px) { .su { --su-header: 57px; } } +.su [x-cloak] { display: none; } +.su code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, monospace; } + +/* connecting, ended, no link */ +.su-note { display: grid; place-items: center; min-height: calc(100vh - var(--su-header)); padding: 2rem 1rem; text-align: center; } +.su-note div { max-width: 28rem; } +.su-note p { color: hsl(var(--muted-foreground)); line-height: 1.5; } +.su-note .su-note-t { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.03em; color: hsl(var(--foreground)); margin-bottom: 0.5rem; } + +.su-wrap { display: flex; justify-content: center; width: min(100% - 2rem, 72rem); margin-inline: auto; min-height: calc(100vh - var(--su-header)); } +.su-main { display: flex; flex-direction: column; justify-content: center; width: 100%; max-width: 32rem; padding: 3rem 0; } + +/* progress: one bar per step; a finished one goes back to that step */ +.su-progress { margin-bottom: 1.5rem; } +.su-progress ol { display: flex; gap: 4px; } +.su-progress li { flex: 1; } +/* the bar is 4px, the button around it 20px, so it's easy to hit */ +.su-progress button { display: block; width: 100%; height: 20px; padding: 8px 0; border: none; background: none; } +.su-progress button::after { content: ""; display: block; height: 4px; border-radius: 2px; background: hsl(var(--border)); + transition: background-color 350ms ease, transform 350ms cubic-bezier(.2, .8, .2, 1); } +.su-progress button.done { cursor: pointer; } +.su-progress button.done::after { background: hsl(var(--foreground) / 0.55); } +.su-progress button.done:hover::after { background: hsl(var(--foreground)); transform: scaleY(1.5); } +.su-progress button.on::after { background: hsl(var(--foreground)); } +.su-progress button:disabled { cursor: default; } +.su-progress button:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; } +.su-progress p { margin-top: 0.25rem; font-size: 0.875rem; color: hsl(var(--muted-foreground)); font-variant-numeric: tabular-nums; } + +/* the question */ +.ask { font-size: clamp(1.875rem, 3.2vw, 2.5rem); line-height: 1.08; letter-spacing: -0.04em; font-weight: 600; text-wrap: balance; margin-bottom: 0.75rem; } +.why { color: hsl(var(--muted-foreground)); font-size: 1.0625rem; line-height: 1.5; margin-bottom: 1.75rem; } +.q > .ask + .opts { margin-top: 1.5rem; } + +.field { width: 100%; height: 2.75rem; padding: 0 0.875rem; font: inherit; border-radius: calc(var(--radius) - 4px); + border: 1px solid hsl(var(--input)); background: hsl(var(--background)); color: hsl(var(--foreground)); + transition: border-color 200ms ease, box-shadow 200ms ease; } +.field:focus { outline: none; box-shadow: 0 0 0 2px hsl(var(--background)), 0 0 0 4px hsl(var(--ring)); } +.field.bad { border-color: var(--bad); } +.field.bad:focus { box-shadow: 0 0 0 2px hsl(var(--background)), 0 0 0 4px var(--bad); } +.field-short { width: 8.5rem; font-variant-numeric: tabular-nums; } +.field-row { display: flex; align-items: center; gap: 0.75rem; } +.field-note { font-size: 0.875rem; color: hsl(var(--muted-foreground)); } +/* marks the value CommandBridge ships with */ +.def { margin-left: 0.5rem; font-size: 0.75rem; font-weight: 400; color: hsl(var(--muted-foreground)); white-space: nowrap; } +.field-row .def { margin-left: 0; } +.fields { display: grid; gap: 1rem; } +.fields label { display: grid; gap: 0.375rem; font-size: 0.875rem; color: hsl(var(--muted-foreground)); } +.fields-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } +.fields-hostport { grid-template-columns: 1fr 7rem; } +.fields-typepass { grid-template-columns: 8.5rem 1fr; } + +/* a choice, listed like the docs navigation: the picked one in full color on a heavier line */ +.opts { border-left: 1px solid hsl(var(--border)); } +.opt { position: relative; display: block; padding: 0.625rem 0 0.625rem 1rem; margin-left: -1px; border-left: 2px solid transparent; cursor: pointer; color: hsl(var(--muted-foreground)); + transition: color 200ms ease, border-color 250ms ease, background-color 200ms ease; } +.opt:hover { color: hsl(var(--foreground)); } +.opt:has(input:checked) { border-left-color: hsl(var(--primary)); color: hsl(var(--foreground)); } +.opt:has(input:focus-visible) { background: hsl(var(--accent) / 0.5); } +.opt input { position: absolute; opacity: 0; pointer-events: none; } +.opt b { display: block; font-weight: 500; } +.opt small { display: block; margin-top: 0.125rem; font-size: 0.875rem; line-height: 1.5; color: hsl(var(--muted-foreground)); } + +/* servers and pairing */ +.list { border-top: 1px solid hsl(var(--border)); } +.list > li { border-bottom: 1px solid hsl(var(--border)); } +.srow { display: grid; grid-template-columns: 1rem 1.125rem minmax(0, 1fr) auto 1.25rem; align-items: center; gap: 0.75rem; min-height: 3rem; } +.srow input[type=checkbox] { width: 1rem; height: 1rem; margin: 0; accent-color: hsl(var(--primary)); cursor: pointer; } +.srow.off { color: hsl(var(--muted-foreground)); } +.srow.off .logo { opacity: 0.4; } +.srow-new { grid-template-columns: 1fr 1fr; padding: 0.5rem 0; } +.srow-new .field { height: 2.375rem; } +.srow-id { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.srow-addr { font-size: 0.875rem; color: hsl(var(--muted-foreground)); font-variant-numeric: tabular-nums; } +.srow-x { width: 1.25rem; height: 1.25rem; padding: 0; border: none; border-radius: 4px; background: none; color: hsl(var(--muted-foreground)); line-height: 1; cursor: pointer; } +.srow-x:hover { color: hsl(var(--foreground)); background: hsl(var(--accent)); } +.logo { width: 1.125rem; height: 1.125rem; object-fit: contain; } +.logo.unknown { filter: grayscale(1); opacity: 0.6; } +html:not(.dark) .su .white { filter: invert(1); } +html:not(.dark) .su .white.unknown { filter: invert(1) grayscale(1); } + +.pair-row { padding: 0.875rem 0; } +.pair-head { display: flex; align-items: center; gap: 0.75rem; } +.pair-state { margin-left: auto; font-size: 0.875rem; color: hsl(var(--muted-foreground)); } +.pair-state.ok { color: hsl(var(--foreground)); } +.pair-state { display: inline-flex; align-items: center; gap: 0.375rem; } +.pair-check { width: 1rem; height: 1rem; fill: none; stroke: currentColor; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; + stroke-dasharray: 24; animation: su-draw 400ms cubic-bezier(.2, .8, .2, 1) 100ms backwards; } +@keyframes su-draw { from { stroke-dashoffset: 24; } } +.cmd { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.625rem; } +.cmd .button-outline { min-width: 5.5rem; } +.cmd code { flex: 1; min-width: 0; padding: 0.5rem 0.75rem; border-radius: calc(var(--radius) - 4px); border: 1px solid hsl(var(--border)); user-select: all; overflow-wrap: anywhere; } + +/* apply */ +.sum { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: 0.625rem 1rem; margin-bottom: 1.75rem; } +.sum dt { color: hsl(var(--muted-foreground)); } +.sum dd { overflow-wrap: anywhere; } +.setup-result p { margin-bottom: 0.625rem; line-height: 1.5; } +.setup-unpaired { margin-top: 1.5rem; } +.setup-unpaired .list { margin-top: 0.75rem; } +.setup-restart { font-weight: 600; } + +.hint { margin-top: 1rem; font-size: 0.9375rem; line-height: 1.5; color: hsl(var(--muted-foreground)); } +.setup-err { margin-top: 0.75rem; font-size: 0.9375rem; color: var(--bad); } + +.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; margin-top: 2rem; } +.actions .button, .actions .button-outline { height: 2.75rem; padding-inline: 1.25rem; font-size: 0.9375rem; } +.link { padding: 0; border: none; background: none; font: inherit; color: hsl(var(--foreground)); cursor: pointer; + text-decoration: underline; text-underline-offset: 3px; text-decoration-color: hsl(var(--border)); } +.link:hover { text-decoration-color: currentColor; } +.srow-add { margin-top: 1rem; font-size: 0.9375rem; } + +@media (max-width: 639px) { .su-main { justify-content: flex-start; padding: 1.5rem 0 3rem; } } + +/* motion: a screen slides in from the side you're heading to; new things fade up into place */ +@keyframes su-in-fwd { from { opacity: 0; transform: translateX(24px); } } +@keyframes su-in-back { from { opacity: 0; transform: translateX(-24px); } } +@keyframes su-rise { from { opacity: 0; transform: translateY(6px); } } +@keyframes su-shake { 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } } +.q.in-fwd { animation: su-in-fwd 380ms cubic-bezier(.2, .8, .2, 1); } +.q.in-back { animation: su-in-back 380ms cubic-bezier(.2, .8, .2, 1); } +.su-wrap, .su-note > div { animation: su-rise 450ms cubic-bezier(.2, .8, .2, 1); } +.setup-err { animation: su-rise 220ms ease-out; } +.field.bad { animation: su-shake 260ms ease-in-out; } +.list > li, .cmd, .setup-result > *, .setup-confirm .cmd { animation: su-rise 300ms cubic-bezier(.2, .8, .2, 1) backwards; } +.pair-state { transition: color 300ms ease; } +.pair-state.ok { animation: su-rise 300ms cubic-bezier(.2, .8, .2, 1); } +.def { animation: su-rise 250ms ease-out; } +.actions .button, .actions .button-outline { transition: background-color 200ms ease, color 200ms ease, opacity 200ms ease, transform 120ms ease; } +.actions .button:active:not(:disabled), .actions .button-outline:active { transform: scale(0.97); } +@media (prefers-reduced-motion: reduce) { + .su *, .su-wrap, .su-note > div { animation: none !important; transition: none !important; } +} diff --git a/src/lib/setup/setup.js b/src/lib/setup/setup.js new file mode 100644 index 0000000..f08f4eb --- /dev/null +++ b/src/lib/setup/setup.js @@ -0,0 +1,339 @@ +// Network setup over the relay. The proxy keeps the plan and applies it only after +// /cb setup confirm in its console. + +const LOGOS = { PAPER: "paper.svg", FOLIA: "folia.svg", VELOCITY: "velocity.svg", FORGE: "forge.svg", SPIGOT: "spigot.svg", BUKKIT: "spigot.svg", FABRIC: "fabric.png" }; +const TLS = { TOFU: "first use", PLAIN: "off", STRICT: "strict" }; +// these logos are drawn in white, so light mode turns them dark +const WHITE = new Set(["paper.svg", "velocity.svg"]); +const PLATFORM = { PAPER: "Paper", FOLIA: "Folia", BUKKIT: "Bukkit", SPIGOT: "Spigot", FORGE: "Forge", FABRIC: "Fabric", VELOCITY: "Velocity" }; + +// what the proxy's codes look like (PairCrypto.java, SetupSession.java) +const PAIR_CODE = /^[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{2}$/; +const CONFIRM_CODE = /^[a-hj-np-z2-9]{6}$/; +const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; +const HOSTNAME = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i; + +// what's wrong with an address servers should connect to, or "" when it's an IP address or a hostname +function hostProblem(host) { + const h = host.trim(); + if (!h) return "Enter the address."; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(h)) return `Leave out ${h.split("//")[0]}//, only the address.`; + if (h === "0.0.0.0") return "Servers can't reach 0.0.0.0. Use the address they connect to."; + if (/^\[?[0-9a-f]*:[0-9a-f:]*\]?$/i.test(h) && h.split(":").length > 2) return ""; // IPv6 + if (/:\d*$/.test(h)) return "Leave out the port, only the address."; + if (/^[\d.]+$/.test(h)) return IPV4.test(h) ? "" : "That isn't a valid IP address. Each of the four parts goes from 0 to 255."; + return HOSTNAME.test(h) ? "" : "That isn't a valid address. Use an IP like 203.0.113.7 or a name like play.example.net."; +} + +function serverAddressProblem(address) { + const a = address.trim(), colon = a.lastIndexOf(":"); + if (colon < 0) return "Write the address as host:port, like 10.0.0.13:25565."; + const port = a.slice(colon + 1); + if (!/^\d+$/.test(port) || +port < 1 || +port > 65535) return "The port must be between 1 and 65535."; + return hostProblem(a.slice(0, colon)); +} + +function cbSetup() { + return { + status: "connecting", // connecting | online | closed | nolink + slow: false, // still connecting after a while + relay: null, + current: null, + known: [], // servers from velocity.toml: { id, address, connected, platform } + a: null, // the answers + step: "name", + dir: 1, // 1 going forward, -1 going back; picks the direction a screen slides in from + err: "", // what's wrong with the answer on this screen + problems: [], // what the proxy said about the plan + codes: Object.create(null), + paired: Object.create(null), + staged: null, + applied: null, // { restartNeeded, errors } + copied: "", + newRow: null, // { id, address } while adding a server + waiting: false, // a plan was sent, the codes aren't back yet + editorBusy: false, + editorError: "", + + init() { + const relay = window.cbRelay(window.cbLink()); + if (!relay) { this.status = "nolink"; return; } + relay.onOpen = () => relay.send({ type: "hello" }); + relay.onMessage = (msg) => this.receive(msg); + relay.onClose = () => { this.status = "closed"; }; + this.relay = relay.connect(); + setTimeout(() => { this.slow = this.status === "connecting"; }, 8000); + }, + + /* ================= steps ================= */ + + steps() { + if (!this.a) return []; + const ws = this.a.endpointType === "WEBSOCKET"; + return [ + { k: "name", t: "Name", s: this.a.serverId }, + { k: "type", t: "Connection", s: ws ? "WebSocket" : "Redis" }, + ws && { k: "port", t: "Port", s: String(this.a.port) }, + { k: "address", t: ws ? "Address" : "Redis", s: ws ? this.a.address : `${this.a.redis.host}:${this.a.redis.port}` }, + { k: "tls", t: "Encryption", s: TLS[this.a.tlsMode] }, + this.a.tlsMode === "STRICT" && { k: "keystore", t: "Keystore", s: this.a.keystore.path }, + { k: "servers", t: "Servers", s: this.picked().map((s) => s.id).join(", ") || "none" }, + { k: "apply", t: "Apply", s: this.applied ? "applied" : "" }, + { k: "pairing", t: "Pairing", s: `${this.pairedCount()} of ${this.picked().length} paired` }, + ].filter(Boolean); + }, + index() { return this.step === "done" ? this.steps().length : this.steps().findIndex((s) => s.k === this.step); }, + isDone(k) { return this.steps().findIndex((s) => s.k === k) < this.index(); }, + canGo(k) { return this.isDone(k) && !this.applied; }, + + go(k) { + if (!this.canGo(k)) return; + if (this.step === "apply" && this.staged) this.relay.send({ type: "unstage" }); + this.err = ""; + this.dir = this.steps().findIndex((s) => s.k === k) < this.index() ? -1 : 1; + this.step = k; + this.focus(); + }, + + enter(k) { return this.step === k ? (this.dir < 0 ? "in-back" : "in-fwd") : ""; }, + + back() { + const st = this.steps(), i = this.index(); + if (i > 0) this.go(st[i - 1].k); + }, + + next() { + if (this.status !== "online" || this.nextDisabled()) return; + if (this.step === "servers" && this.newRow) { + // an untouched extra row is just closed; a filled one is added first + if (!this.newRow.id.trim()) this.newRow = null; + else if ((this.err = this.addRow())) return; + } + this.err = this.check(); + if (this.err) return; + if (this.step === "servers") { this.sendPlan(); return; } + if (this.step === "pairing") { this.finish(); return; } + const st = this.steps(), i = this.index(); + this.dir = 1; + this.step = st[i + 1].k; + if (this.step === "apply") this.relay.send({ type: "stage" }); + this.focus(); + }, + + nextDisabled() { + if (this.step === "pairing") return this.pairedCount() < this.picked().length; + if (this.step === "servers") return this.waiting || !this.picked().length; + return this.step === "apply"; + }, + + nextLabel() { + if (this.step === "servers") return this.waiting ? "Making codes…" : `Continue with ${this.picked().length} ${this.picked().length === 1 ? "server" : "servers"}`; + if (this.step === "pairing") { + const left = this.picked().length - this.pairedCount(); + return left ? `Waiting for ${left} ${left === 1 ? "server" : "servers"}` : "Finish"; + } + return "Continue"; + }, + + // the same rules the proxy checks, so most mistakes show before the plan is sent + check() { + const a = this.a; + if (this.step === "name" && !/^[a-z0-9-]{1,32}$/.test(a.serverId)) return "Use a to z, 0 to 9 and dashes, up to 32 characters."; + if (this.step === "port" && !(a.port >= 1 && a.port <= 65535)) return "The port must be between 1 and 65535."; + if (this.step === "address" && a.endpointType === "WEBSOCKET") return hostProblem(a.address); + if (this.step === "address" && a.endpointType === "REDIS") + return hostProblem(a.redis.host) || (a.redis.port >= 1 && a.redis.port <= 65535 ? "" : "The Redis port must be between 1 and 65535."); + if (this.step === "keystore" && !a.keystore.path.trim()) return "Enter the path of the keystore file."; + return ""; + }, + + focus() { + this.$nextTick(() => document.querySelector(`.q[data-step="${this.step}"]`)?.querySelector("input[type=text], input:checked")?.focus()); + }, + + keydown(e) { + if (e.key !== "Enter" || e.isComposing) return; + // Enter anywhere on the page continues, except on the site's own links and buttons + if (e.target !== document.body && !e.target.closest(".su")) return; + if (e.target.matches("[data-f=new-id], [data-f=new-address]")) { + e.preventDefault(); + this.err = this.addRow(); + if (!this.err) this.startRow(); // straight on to the next one + return; + } + if (e.target.closest("button")) return; + e.preventDefault(); + this.next(); + }, + + /* ================= servers ================= */ + + picked() { return this.a ? this.a.servers.filter((s) => s.on) : []; }, + pairedCount() { return this.picked().filter((s) => this.paired[s.id]).length; }, + platformOf(s) { return this.paired[s.id]?.platform || s.platform; }, + logo(s) { return "/lib/editor/logos/" + (LOGOS[this.platformOf(s)] || "paper.svg"); }, + logoClass(s) { return [WHITE.has(this.logo(s).split("/").pop()) && "white", !this.platformOf(s) && "unknown"].filter(Boolean).join(" "); }, + software(s) { + const p = this.paired[s.id]; + return p ? [PLATFORM[p.platform] || p.platform, p.version].filter(Boolean).join(" ") : ""; + }, + added() { return this.picked().filter((s) => s.isNew); }, + + // a new server most likely runs where the proxy does, on the port after the highest one in use + suggestedAddress() { + const a = this.a; + const host = a.endpointType === "WEBSOCKET" && !hostProblem(a.address) ? a.address.trim() : a.servers[0]?.address.replace(/:\d+$/, "") || "127.0.0.1"; + const ports = a.servers.map((s) => +s.address.split(":").pop()).filter((n) => n > 0 && n < 65535); + return `${host}:${ports.length ? Math.max(...ports) + 1 : 25566}`; + }, + startRow() { + this.newRow = { id: "", address: this.suggestedAddress() }; + this.$nextTick(() => document.querySelector("[data-f=new-id]")?.focus()); + }, + addRow() { + const r = this.newRow; + if (!r) return ""; + const id = r.id.trim(), address = r.address.trim(); + if (!id) return "Give the new server a name, the same one it has in velocity.toml."; + if (!/^[A-Za-z0-9._-]{1,32}$/.test(id)) return "A server name can use letters, digits, dots, dashes and underscores."; + if (this.a.servers.some((s) => s.id === id)) return `There is already a server called ${id}.`; + const problem = serverAddressProblem(address); + if (problem) return problem; + this.a.servers.push({ id, address, on: true, isNew: true, platform: null }); + this.newRow = null; + return ""; + }, + removeRow(s) { this.a.servers = this.a.servers.filter((x) => x !== s); }, + + // a different connection means every server pairs again + connectionChanged() { + const a = this.a, c = this.current; + return a.endpointType !== c.endpointType || a.tlsMode !== c.tlsMode || (a.endpointType === "WEBSOCKET" && a.port !== c.port); + }, + + /* ================= plan, pairing, apply ================= */ + + planJson() { + const a = this.a, ws = a.endpointType === "WEBSOCKET"; + return { + serverId: a.serverId.trim(), + endpointType: a.endpointType, + port: Number(a.port), + address: ws ? a.address.trim() : "", + redis: ws ? null : { host: a.redis.host.trim(), port: Number(a.redis.port), username: a.redis.username, password: a.redis.password || null }, + tlsMode: a.tlsMode, + keystore: a.tlsMode === "STRICT" ? { path: a.keystore.path.trim(), type: a.keystore.type, password: a.keystore.password || null } : null, + servers: this.picked().map((s) => ({ id: s.id, address: s.address, addToVelocity: !!s.isNew })), + }; + }, + + sendPlan() { + this.problems = []; + this.waiting = true; + this.relay.send({ type: "plan", plan: this.planJson() }); + }, + + unpaired() { return this.picked().filter((s) => !this.paired[s.id]); }, + + // the setup is over; the proxy closes it for any page that comes back to this link + finish() { + this.dir = 1; + this.step = "done"; + this.relay.send({ type: "finish" }); + }, + + // the proxy opens an editor session and sends back its link + openEditor() { + this.editorBusy = true; + this.editorError = ""; + this.relay.send({ type: "editor" }); + }, + + copy(text) { + navigator.clipboard?.writeText(text).catch((e) => console.warn("Copy failed:", e.message)); + this.copied = text; + setTimeout(() => { if (this.copied === text) this.copied = ""; }, 1500); + }, + + /* ================= connection ================= */ + + receive(msg) { + if (msg.type === "state") { + const first = !this.a; + this.current = msg.current; + this.known = msg.servers || []; + // codes end up in a console, so anything that isn't exactly a code is left out + this.codes = Object.assign(Object.create(null), Object.fromEntries(Object.entries(msg.codes || {}).filter(([, c]) => PAIR_CODE.test(c)))); + this.paired = Object.assign(Object.create(null), msg.paired || {}); + this.staged = CONFIRM_CODE.test(msg.staged || "") ? msg.staged : null; + if (first) { + this.a = this.answersFrom(msg); + // a reload in the middle of the setup lands where it was + if (msg.applied) this.applied = this.applied || { restartNeeded: false, errors: [] }; + this.step = msg.applied ? "pairing" : msg.plan ? "apply" : "name"; + if (this.step === "apply" && !this.staged) this.relay.send({ type: "stage" }); + setInterval(() => this.relay.send({ type: "ping" }), 60_000); + } else if (this.waiting) { + this.waiting = false; + this.dir = 1; + this.step = "apply"; + this.relay.send({ type: "stage" }); + } + this.status = "online"; + this.focus(); + } else if (msg.type === "plan-error") { + this.waiting = false; + if (this.step === "apply") this.step = "servers"; + this.problems = msg.problems || []; + } else if (msg.type === "paired") { + this.paired = Object.assign(Object.create(null), this.paired, { [msg.id]: { platform: msg.platform, version: msg.version } }); + delete this.codes[msg.id]; + } else if (msg.type === "editor") { + // only ever to this site's editor, whatever the message says + let url; + try { url = new URL(msg.url, location.href); } catch { url = null; } + if (url && url.origin === location.origin && url.pathname === "/editor/" && /^#[0-9a-f]{32}:[A-Za-z0-9_-]{43}$/.test(url.hash)) location.href = url.href; + else { this.editorBusy = false; this.editorError = "The proxy sent a link that isn't the editor's. Run /cb editor instead."; } + } else if (msg.type === "editor-error") { + this.editorBusy = false; + this.editorError = `The proxy couldn't open the editor: ${msg.error}. Run /cb editor instead.`; + } else if (msg.type === "staged") { + this.staged = CONFIRM_CODE.test(msg.code || "") ? msg.code : null; + } else if (msg.type === "applied") { + const errors = (msg.errors || []).map(String); + if (errors.length) { + this.applied = null; + this.step = "apply"; + this.err = errors.join("\n"); + return; + } + this.err = ""; + this.staged = null; + this.applied = { restartNeeded: !!msg.restartNeeded, errors }; + this.dir = 1; + this.step = "pairing"; + } + }, + + answersFrom(msg) { + const c = msg.current, p = msg.plan; + const fromToml = (msg.servers || []).map((s) => ({ id: s.id, address: s.address, on: true, isNew: false, platform: s.platform, connected: s.connected })); + const servers = p + ? [...fromToml.map((s) => ({ ...s, on: p.servers.some((x) => x.id === s.id) })), + ...p.servers.filter((x) => x.addToVelocity).map((x) => ({ id: x.id, address: x.address, on: true, isNew: true, platform: null }))] + : fromToml; + // a guess for the address: the bind host when it's one servers can reach + const guess = msg.saved?.address || (/^(0\.0\.0\.0|127\.|localhost|::)/.test(c.bindHost || "") ? "" : c.bindHost || ""); + return { + serverId: p?.serverId ?? c.serverId, + endpointType: p?.endpointType ?? c.endpointType, + port: p?.port || c.port, + address: p?.address ?? guess, + redis: { host: p?.redis?.host ?? c.redis.host, port: p?.redis?.port ?? c.redis.port, username: p?.redis?.username ?? c.redis.username, password: "" }, + tlsMode: p?.tlsMode ?? c.tlsMode, + keystore: { path: p?.keystore?.path ?? c.keystore.path, type: p?.keystore?.type ?? (c.keystore.type || "PKCS12"), password: "" }, + servers, + }; + }, + }; +} diff --git a/src/setup.njk b/src/setup.njk new file mode 100644 index 0000000..7010d71 --- /dev/null +++ b/src/setup.njk @@ -0,0 +1,228 @@ +--- +layout: layouts/base.njk +title: Network Setup +permalink: /setup/ +description: "Set up your CommandBridge network from the browser." +hideFooter: true +eleventyExcludeFromCollections: true +appPage: true +--- + +{% macro pairRows(list) %} +
    + +
+{% endmacro %} + + + + + +
+ +
+ + +
+ + +
diff --git a/tests/app-security.test.mjs b/tests/app-security.test.mjs new file mode 100644 index 0000000..3f28ddb --- /dev/null +++ b/tests/app-security.test.mjs @@ -0,0 +1,122 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; + +function app(file, factory) { + const context = vm.createContext({ setInterval() {}, setTimeout() {} }); + vm.runInContext(readFileSync(new URL(`../src/lib/${file}`, import.meta.url), "utf8"), context); + return vm.runInContext(`${factory}()`, context); +} + +test("setup treats prototype-named servers as unpaired until explicitly paired", () => { + const setup = app("setup/setup.js", "cbSetup"); + const servers = ["constructor", "toString", "__proto__"].map((id) => ({ id, address: "localhost:25565", on: true })); + setup.a = { servers }; + setup.focus = () => {}; + setup.current = {}; + setup.receive({ type: "state", current: {}, paired: {}, codes: {} }); + assert.equal(setup.pairedCount(), 0); + assert.equal(setup.unpaired().length, 3); + assert.equal(setup.codes.constructor, undefined); + setup.receive({ type: "paired", id: "constructor", platform: "PAPER", version: "1.21" }); + assert.equal(setup.pairedCount(), 1); + assert.equal(setup.paired.__proto__, undefined); + setup.receive({ type: "paired", id: "__proto__", platform: "FOLIA", version: "1.21" }); + assert.equal(setup.pairedCount(), 2); + assert.equal(Object.getPrototypeOf(setup.paired), null); +}); + +test("a failed setup apply stays retryable instead of claiming the proxy is set up", () => { + const setup = app("setup/setup.js", "cbSetup"); + setup.step = "apply"; + setup.staged = "q8n2fl"; + setup.receive({ type: "applied", restartNeeded: false, errors: ["Could not save config.yml"] }); + assert.equal(setup.applied, null); + assert.equal(setup.step, "apply"); + assert.equal(setup.staged, "q8n2fl"); + assert.equal(setup.err, "Could not save config.yml"); + setup.receive({ type: "applied", restartNeeded: true, errors: [] }); + assert.equal(setup.step, "pairing"); + assert.equal(setup.err, ""); + assert.equal(setup.applied.restartNeeded, true); +}); + +function editor() { + const editor = app("editor/editor.js", "cbEditor"), sent = []; + editor.relay = { send: (msg) => sent.push(msg) }; + editor.refresh = editor.rebuild = () => {}; + editor.blocking = () => []; + editor.changes = () => Object.keys(editor.out).length; + return { editor, sent }; +} + +test("a reload cancels a pending set instead of offering a blind confirm command", () => { + const { editor: e, sent } = editor(); + e.receive({ type: "state", scripts: {}, pending: "k3m9x2", draft: { files: { "evil.yml": "unreviewed" } } }); + assert.equal(e.confirmCode, null); + assert.equal(sent[0].type, "unstage"); + assert.equal(e.results.length, 1); + assert.ok(e.results[0].offer); +}); + +test("an old staged reply cannot confirm edits made while Save was in flight", () => { + const { editor: e, sent } = editor(); + e.out = { "script.yml": "old" }; + e.save(); + e.out["script.yml"] = "new"; + e.receive({ type: "staged", errors: {}, code: "k3m9x2" }); + assert.equal(e.confirmCode, null); + assert.equal(e.busy, false); + assert.equal(sent.at(-1).type, "unstage"); +}); + +test("Save cannot overlap pending checks even after an edit cancels that set", () => { + const { editor: e, sent } = editor(); + e.out = { "script.yml": "old" }; + e.save(); + e.out["script.yml"] = "new"; + e.cancelStage(); + e.save(); + assert.equal(sent.filter((m) => m.type === "stage").length, 1); + e.receive({ type: "staged", errors: {}, code: "k3m9x2" }); + assert.equal(e.confirmCode, null); + e.save(); + assert.equal(sent.filter((m) => m.type === "stage").length, 2); +}); + +test("a malformed confirm code never becomes a console command", () => { + const { editor: e, sent } = editor(); + e.out = { "script.yml": "edited" }; + e.save(); + e.receive({ type: "staged", errors: {}, code: "k3m9x2\nop attacker" }); + assert.equal(e.confirmCode, null); + assert.equal(sent.at(-1).type, "unstage"); + assert.match(e.results[0].text, /invalid confirmation code/); +}); + +test("a matching staged set still offers its valid confirm code", () => { + const { editor: e } = editor(); + e.out = { "script.yml": "edited" }; + e.save(); + e.receive({ type: "staged", errors: {}, code: "k3m9x2" }); + assert.equal(e.confirmCode, "k3m9x2"); +}); + +test("stored groups cannot inject a selector or break rendering with invalid fields", () => { + const context = vm.createContext({}); + vm.runInContext(readFileSync(new URL("../src/lib/editor/editor.js", import.meta.url), "utf8"), context); + const validGroup = vm.runInContext("validGroup", context); + const group = { id: "abcd1234", title: "Group", color: "#8b5cf6", members: ["test.yml"], x: 0, y: 0, w: 400, h: 200 }; + assert.ok(validGroup(group)); + for (const invalid of [null, { id: 'x"]' }, { title: null }, { color: "url(https://evil.test/)" }, { members: {} }, { members: [null] }, { x: "not a number" }]) { + assert.ok(!validGroup(invalid && { ...group, ...invalid })); + } +}); + +test("Cloudflare Pages blocks framing both privileged apps", () => { + const headers = readFileSync(new URL("../src/assets/_headers", import.meta.url), "utf8"); + for (const path of ["editor", "setup"]) { + assert.ok(headers.includes(`/${path}/*\n X-Frame-Options: DENY\n Content-Security-Policy: frame-ancestors 'none'`)); + } +}); diff --git a/tests/browser/editor-firefox.mjs b/tests/browser/editor-firefox.mjs new file mode 100644 index 0000000..4d4cb11 --- /dev/null +++ b/tests/browser/editor-firefox.mjs @@ -0,0 +1,99 @@ +// The editor in the installed Firefox, driven over WebDriver BiDi (no extra dependencies). Same setup as editor-ui.mjs. +// Run: node tests/browser/editor-firefox.mjs +import { fakeProxy } from "./fake-proxy.mjs"; +import { spawn } from "node:child_process"; +import { readFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const D = new URL("../fixtures/", import.meta.url); +const scripts = Object.fromEntries(["docs-examples-0.yml", "docs-examples-2.yml"].map((f) => [f, readFileSync(new URL(f, D), "utf8")])); +const proxy = await fakeProxy((m) => { if (m.type === "hello") proxy.send({ type: "state", scripts, servers: { "proxy-1": { location: "VELOCITY", platform: "VELOCITY" }, lobby: { location: "BACKEND", platform: "PAPER" } } }); }); + +const profile = mkdtempSync(join(tmpdir(), "cb-ff-")); +const port = 9300 + Math.floor(Math.random() * 500); +const ff = spawn("firefox", ["--headless", "--no-remote", "--profile", profile, "--remote-debugging-port", String(port), "--window-size=1600,1000"], { stdio: "ignore" }); +let ws; +for (let i = 0; i < 60 && !ws; i++) { + await new Promise((r) => setTimeout(r, 500)); + try { ws = new WebSocket(`ws://127.0.0.1:${port}/session`); await new Promise((ok, no) => { ws.onopen = ok; ws.onerror = no; }); } catch { ws = null; } +} +let msgId = 0; +const waiting = new Map(); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && waiting.has(m.id)) { waiting.get(m.id)(m); waiting.delete(m.id); } }; +const cmd = (method, params) => new Promise((ok, no) => { const i = ++msgId; waiting.set(i, (m) => (m.type === "error" ? no(new Error(method + ": " + m.message)) : ok(m.result))); ws.send(JSON.stringify({ id: i, method, params })); }); +await cmd("session.new", { capabilities: {} }); +const ctx = (await cmd("browsingContext.getTree", {})).contexts[0].context; +// results come back as JSON text, since BiDi's own serialization of objects is verbose +const js = async (expr) => { + const r = await cmd("script.evaluate", { expression: `(async () => JSON.stringify(await (${expr})))()`, target: { context: ctx }, awaitPromise: true }); + if (r.type === "exception") throw new Error(r.exceptionDetails.text); + return r.result.value === undefined ? undefined : JSON.parse(r.result.value); +}; +const act = (actions) => cmd("input.performActions", { context: ctx, actions }); +const click = async (x, y, button = 0) => act([{ type: "pointer", id: "m", actions: [{ type: "pointerMove", x: Math.round(x), y: Math.round(y) }, { type: "pointerDown", button }, { type: "pointerUp", button }] }]); +const keys = async (...seq) => act([{ type: "key", id: "k", actions: seq.flatMap((k) => (Array.isArray(k) ? [...k.map((v) => ({ type: "keyDown", value: v })), ...k.slice().reverse().map((v) => ({ type: "keyUp", value: v }))] : [{ type: "keyDown", value: k }, { type: "keyUp", value: k }])) }]); +const CTRL = ""; +const pause = (ms) => new Promise((r) => setTimeout(r, ms)); + +let fails = 0; +const check = (n, c, x = "") => { console.log((c ? "PASS " : "FAIL ") + n + (c ? "" : " " + x)); if (!c) fails++; }; +try { + await cmd("browsingContext.navigate", { context: ctx, url: `http://localhost:8080/editor/#${proxy.link}`, wait: "complete" }); + for (let i = 0; i < 40 && !(await js(`document.querySelectorAll(".drawflow-node").length > 5`)); i++) await pause(250); + await js(`(document.querySelector("[x-data*=cookie], .cookie, #cookie-banner")?.remove(), 1)`); + const A = `Alpine.$data(document.querySelector(".ed"))`; + const count = () => js(`document.querySelectorAll(".drawflow-node").length`); + + // a node, then Ctrl+A, Ctrl+C, Ctrl+V + const cmdHead = await js(`(() => { const n = [...document.querySelectorAll(".cb-command")].find((n) => n.querySelector('[data-f="name"]').value === "alert"); const a = ${A}, r = a.rectOf(n.id.slice(5)); a.centerOn(r.x + 200, r.y + 100, 1); const b = n.querySelector(".nh").getBoundingClientRect(); return { x: b.left + 120, y: b.top + 15 }; })()`); + await click(cmdHead.x, cmdHead.y); + check("click selects a node", (await js(`${A}.selectionSize`)) === 1); + check("keys go to the editor's key field", (await js(`document.activeElement.className`)) === "ed-keys", await js(`document.activeElement.tagName + "." + document.activeElement.className`)); + await keys([CTRL, "a"]); + const all = await count(); + check("Ctrl+A selects everything", (await js(`${A}.selectionSize`)) === all, `${await js(`${A}.selectionSize`)} of ${all}`); + const empty = await js(`(() => { const r = document.getElementById("ed-canvas").getBoundingClientRect(); return { x: r.left + 8, y: r.top + 8 }; })()`); + await click(empty.x, empty.y); // empty canvas: nothing selected + await click(cmdHead.x, cmdHead.y); + await keys([CTRL, "c"]); + await keys([CTRL, "v"]); + await pause(200); + check("Ctrl+C, Ctrl+V paste a copy", (await count()) === all + 1, `${await count()} vs ${all + 1}`); + + // typing into a field after clicking it + // a description field nothing else covers (the pasted copy lands on top of the original) + const desc = await js(`(() => { for (const t of document.querySelectorAll('.cb-command [data-f="description"]')) { const b = t.getBoundingClientRect(), x = b.left + 20, y = b.top + 10; if (document.elementFromPoint(x, y) === t) return { x, y }; } })()`); + await click(desc.x, desc.y); + await keys("x", "y"); + check("a clicked field takes typing", /xy/.test(await js(`document.activeElement.value`)) && (await js(`document.activeElement.dataset.f`)) === "description"); + // every kind of field takes typing after a click, also a click on its frame + for (const [label, sel, frame] of [["command name", '.cb-command .slashed', true], ["suggestions", '.cb-arg [data-f="suggestions"]'], ["delay", '.cb-defaults [data-f="d-delay"]'], + ["alias row", '.cb-alias .tag input'], ["new alias", '.cb-alias .al-new', true], ["step command", '.cb-run .cmd']]) { + const pt = await js(`(() => { for (const el of document.querySelectorAll(${JSON.stringify(sel)})) { const n = el.closest(".drawflow-node"), a = ${A}, r = a.rectOf(n.id.slice(5)); a.centerOn(r.x + r.w / 2, r.y + r.h / 2, 1); + const b = el.getBoundingClientRect(), x = b.left + (${!!frame} ? 4 : Math.min(30, b.width / 2)), y = b.top + b.height / 2; if (el.contains(document.elementFromPoint(x, y)) || document.elementFromPoint(x, y) === el) return { x, y }; } })()`); + if (!pt) { check(label + " is reachable", false); continue; } + await click(pt.x, pt.y); + await keys("q", "z"); + const got = await js(`(() => { const a = document.activeElement; return a.isContentEditable ? a.textContent : a.value; })()`); + check(`typing goes into the ${label}`, /qz/.test(got || "") && (frame ? /qz$/.test(got) : true), String(got)); + } + check("fields allow text selection (caret shows in Firefox)", (await js(`getComputedStyle(document.querySelector('.cb-command [data-f="name"]')).userSelect`)) !== "none"); + + // dragging an argument pill into a command, with a preview while dragging + const drag = await js(`(() => { const run = [...document.querySelectorAll(".cb-run")].find((n) => n.querySelector('.ph[data-arg="amount"]')); const a = ${A}, r = a.rectOf(run.id.slice(5)); a.centerOn(r.x + 200, r.y + 150, 1); + const p = run.querySelector(".pill").getBoundingClientRect(), c = run.querySelector(".cmd").getBoundingClientRect(); window.__run = run.id; return { px: p.left + 10, py: p.top + 8, cx: c.left + 8, cy: c.top + c.height / 2, before: run.querySelectorAll(".cmd .ph").length }; })()`); + await act([{ type: "pointer", id: "m", actions: [{ type: "pointerMove", x: Math.round(drag.px), y: Math.round(drag.py) }, { type: "pointerDown", button: 0 }, + { type: "pointerMove", x: Math.round(drag.cx), y: Math.round(drag.cy), duration: 200 }] }]); + check("dragging shows a preview in the text", (await js(`document.querySelectorAll("#" + window.__run + " .cmd .ph.preview").length`)) === 1); + await act([{ type: "pointer", id: "m", actions: [{ type: "pointerUp", button: 0 }] }]); + check("dropping inserts the argument", (await js(`document.querySelectorAll("#" + window.__run + " .cmd .ph").length`)) === drag.before + 1); + check("still on the editor page", (await js(`location.pathname`)) === "/editor/"); +} finally { + ff.kill(); + proxy.close(); + ws?.close(); + rmSync(profile, { recursive: true, force: true }); +} +console.log(fails ? `${fails} failed` : "all passed"); +process.exit(fails ? 1 : 0); diff --git a/tests/browser/editor-state.mjs b/tests/browser/editor-state.mjs new file mode 100644 index 0000000..5cf0f7f --- /dev/null +++ b/tests/browser/editor-state.mjs @@ -0,0 +1,273 @@ +// Run with the wiki dev server on 8080: node tests/browser/editor-state.mjs +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { chromium } from "/usr/lib/node_modules/omniroute/node_modules/playwright-core/index.mjs"; +import YAML from "yaml"; + +const script = (name = "example") => `# script header +version: 4 +name: ${name} +permissions: + enabled: true + silent: false +register: + - id: proxy-1 # registration comment + location: VELOCITY +defaults: + run-as: CONSOLE + execute: + - id: lobby # target comment + location: BACKEND + custom-target: keep +args: + - name: first # first argument + custom-arg: first + - name: second # second argument + custom-arg: second + - name: third # third argument + custom-arg: third +commands: + - command: say first # first step + custom-step: first + - command: say second # second step + custom-step: second + - command: say third # third step + custom-step: third +`; +const servers = { "proxy-1": { location: "VELOCITY" }, lobby: { location: "BACKEND" } }; +const source = readFileSync(new URL("../../src/lib/editor/editor.js", import.meta.url), "utf8"); +const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || process.env.HOME + "/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome" }); + +async function open(scripts = { "example.yml": script() }) { + const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.addInitScript((state) => { window.__initialState = state; window.__messages = []; }, { type: "state", scripts, servers }); + await page.route("**/lib/editor/editor.js", (route) => route.fulfill({ contentType: "text/javascript", body: source })); + await page.route("**/lib/editor/relay.js", (route) => route.fulfill({ contentType: "text/javascript", body: ` + window.cbLink = () => "test"; + window.cbRelay = () => ({ + send(msg) { window.__messages.push(msg); }, + connect() { queueMicrotask(() => this.onMessage(window.__initialState)); return this; } + }); + ` })); + await page.goto("http://localhost:8080/editor/"); + await page.waitForFunction(() => window.Alpine && Alpine.$data(document.querySelector(".ed")).status === "online"); + const act = (fn, arg) => page.evaluate(({ fn, arg }) => (0, eval)(`(${fn})`)(Alpine.$data(document.querySelector(".ed")), arg), { fn: fn.toString(), arg }); + const state = () => act((a) => ({ out: a.out, scripts: a.scripts, deletions: a.deletions, changes: a.changes(), blocking: a.blocking(), draft: a.yamlDraft, error: a.yamlError, code: a.confirmCode, messages: window.__messages })); + return { page, act, state, errors }; +} + +async function scenario(name, run) { + const app = await open(name.startsWith("unread") ? { "example.yml": script(), "broken.yml": "name: [\n" } : undefined); + try { + await run(app); + assert.deepEqual(app.errors, [], "browser errors"); + console.log("PASS " + name); + } finally { await app.page.close(); } +} + +try { + await scenario("unread malformed files never become inferred deletions", async ({ act, state }) => { + assert.equal((await state()).changes, 0); + await act((a) => { + const field = document.querySelector('.cb-command [data-f="description"]'); + field.value = "local edit"; + a.onInput({ target: field }); + a.save(); + }); + let s = await state(); + assert.deepEqual(s.deletions, []); + assert.deepEqual(Object.keys(s.messages.find((m) => m.type === "stage").files), ["example.yml"]); + await act((a) => { a.cancelStage(); a.selectAll(); a.deleteSelection(); a.refresh(); }); + s = await state(); + assert.deepEqual(s.deletions, ["example.yml"]); + assert.equal(s.scripts["broken.yml"], "name: [\n"); + await act((a) => { a.rebuild(); a.restoreDraft({ files: { "example.yml": "name: [" }, deletions: [] }); }); + s = await state(); + assert.deepEqual(s.deletions, [], "an unread restored draft is not a request to delete its saved file"); + assert.equal(s.out["example.yml"], undefined); + assert.match(s.scripts["example.yml"], /name: example/); + }); + + await scenario("external changes freeze the dirty baseline and block rollback saves", async ({ act, state }) => { + await act((a, other) => { + a.receive({ type: "state", scripts: { ...a.scripts, "other.yml": other }, servers: a.servers }); + const field = document.querySelector('.cb-command [data-f="description"]'); + field.value = "local edit"; + a.onInput({ target: field }); + a.receive({ type: "state", scripts: { ...a.scripts, "other.yml": other.replace("say third", "say remote"), "new.yml": other.replace("name: other", "name: added") }, servers: a.servers }); + a.save(); + }, script("other")); + let s = await state(); + assert.match(s.scripts["other.yml"], /say third/); + assert.match(s.out["other.yml"], /say third/); + assert.equal(s.scripts["new.yml"], undefined); + assert.deepEqual(s.deletions, []); + assert.match(s.blocking.at(-1).text, /changed on the proxy/); + assert.equal(s.messages.some((m) => m.type === "stage"), false); + await act((a) => { + const external = a.scripts["example.yml"].replace("say third", "say remote"); + a.receive({ type: "state", scripts: { "example.yml": external, "broken.yml": "name: [" }, servers: a.servers }); + a.refresh(); + }); + s = await state(); + assert.match(s.out["example.yml"], /say third/); + assert.doesNotMatch(s.out["example.yml"], /say remote/); + await act((a) => a.rebuild()); + s = await state(); + assert.match(s.out["example.yml"], /say remote/); + assert.equal(s.changes, 0); + assert.deepEqual(s.deletions, []); + }); + + await scenario("an external edit to a just-saved file is not mistaken for its acknowledgement", async ({ act, state }) => { + await act((a) => { + const field = document.querySelector('.cb-command [data-f="description"]'); + field.value = "saved edit"; + a.onInput({ target: field }); + a.save(); + a.receive({ type: "staged", errors: {}, code: "k3m9x2" }); + a.receive({ type: "applied", results: { "example.yml": [] } }); + const saved = a.out["example.yml"]; + field.value = "next local edit"; + a.onInput({ target: field }); + a.receive({ type: "state", scripts: { "example.yml": saved }, servers: a.servers }); + }); + assert.deepEqual((await state()).blocking, []); + await act((a) => a.receive({ type: "state", scripts: { "example.yml": a.scripts["example.yml"].replace("say third", "say remote") }, servers: a.servers })); + const s = await state(); + assert.match(s.out["example.yml"], /next local edit/); + assert.doesNotMatch(s.out["example.yml"], /say remote/); + assert.match(s.blocking.at(-1).text, /changed on the proxy/); + }); + + await scenario("saved deletion and reorder preserve source YAML through refresh and undo", async ({ act, state }) => { + await act((a) => { + const snap = JSON.parse(a.serialize()); + const args = snap.nodes.filter((n) => n.kind === "arg"); + const removed = args[0].id; + snap.nodes = snap.nodes.filter((n) => n.id !== removed); + snap.edges = snap.edges.filter(([from, , to]) => from !== removed && to !== removed); + [args[1].y, args[2].y] = [args[2].y, args[1].y]; + const steps = snap.nodes.filter((n) => n.kind === "run"); + const cmd = snap.nodes.find((n) => n.kind === "command"); + snap.edges = snap.edges.filter(([, out, to, inp]) => !(out === "output_1" && inp === "input_1" && steps.some((n) => n.id === to))); + snap.edges.push([cmd.id, "output_1", steps[2].id, "input_1"], [steps[2].id, "output_1", steps[1].id, "input_1"]); + snap.nodes = snap.nodes.filter((n) => n.id !== steps[0].id); + a.buildFrom(snap); + a.save(); + a.receive({ type: "staged", errors: {}, code: "k3m9x2" }); + const saved = a.out["example.yml"]; + a.receive({ type: "applied", results: { "example.yml": [] } }); + a.receive({ type: "state", scripts: { "example.yml": saved }, servers: a.servers }); + a.refresh(); + }); + let s = await state(); + const saved = s.scripts["example.yml"]; + assert.equal(s.out["example.yml"], saved); + assert.deepEqual(YAML.parse(saved).args.map((a) => [a.name, a["custom-arg"]]), [["third", "third"], ["second", "second"]]); + assert.deepEqual(YAML.parse(saved).commands.map((c) => [c.command, c["custom-step"]]), [["say third", "third"], ["say second", "second"]]); + for (const comment of ["third argument", "second argument", "third step", "second step", "target comment"]) assert.ok(saved.includes(comment)); + await act((a) => a.undo()); + s = await state(); + assert.deepEqual(YAML.parse(s.out["example.yml"]).args.map((a) => a["custom-arg"]), ["first", "second", "third"]); + assert.match(s.out["example.yml"], /first argument/); + assert.match(s.out["example.yml"], /first step/); + await act((a) => { a.redo(); a.refresh(); }); + assert.equal((await state()).out["example.yml"], saved); + }); + + await scenario("Save flushes valid YAML and blocks pending invalid YAML", async ({ act, state }) => { + await act((a) => { + a.yamlPick = "example.yml"; + a.yamlInput({ target: { value: a.out["example.yml"].replace("say third", "say latest") } }); + a.save(); + }); + let s = await state(); + assert.match(s.messages.find((m) => m.type === "stage").files["example.yml"], /say latest/); + assert.equal(s.draft, null); + await act((a) => { + a.receive({ type: "staged", errors: {}, code: "k3m9x2" }); + a.yamlInput({ target: { value: "name: [" } }); + }); + s = await state(); + assert.equal(s.code, null); + assert.equal(s.messages.at(-1).type, "unstage"); + await act((a) => { + a.receive({ type: "staged", errors: {}, code: "k3m9x2" }); + a.save(); + }); + s = await state(); + assert.equal(s.code, null); + assert.ok(s.error); + assert.ok(s.blocking.length); + assert.equal(s.messages.filter((m) => m.type === "stage").length, 1); + assert.ok(s.changes); + await act((a) => { + a.yamlInput({ target: { value: a.out["example.yml"].replace(/args:[\s\S]*?commands:/, "args: {}\ncommands:") } }); + a.save(); + }); + assert.match((await state()).error, /Should be a list/); + await act((a) => a.rebuild()); + assert.equal((await state()).draft, null); + assert.equal((await state()).changes, 0); + await act((a) => { + a.yamlInput({ target: { value: "name: [" } }); + a.receive({ type: "state", scripts: { "example.yml": a.scripts["example.yml"].replace("say third", "say remote") }, servers: a.servers }); + a.save(); + }); + s = await state(); + assert.equal(s.changes, 1, "a pending invalid draft is unsaved work even on a clean graph"); + assert.doesNotMatch(s.scripts["example.yml"], /say remote/); + assert.match(s.blocking.at(-1).text, /changed on the proxy/); + assert.equal(s.messages.filter((m) => m.type === "stage").length, 1); + }); + + await scenario("YAML rebuild and history retain every script's indexed source", async ({ act, state }) => { + await act((a, other) => { + a.receive({ type: "state", scripts: { ...a.scripts, "other.yml": other }, servers: a.servers }); + const snap = JSON.parse(a.serialize()); + const args = snap.nodes.filter((n) => n.kind === "arg").slice(0, 3); + [args[0].y, args[2].y] = [args[2].y, args[0].y]; + a.buildFrom(snap); + a.yamlPick = "other.yml"; + a.yamlInput({ target: { value: a.out["other.yml"].replace("say third", "say typed") } }); + a.yamlApply(); + a.refresh(); + }, script("other")); + let s = await state(); + assert.deepEqual(YAML.parse(s.out["example.yml"]).args.map((a) => [a.name, a["custom-arg"]]), [["third", "third"], ["second", "second"], ["first", "first"]]); + await act((a) => { a.undo(); a.refresh(); }); + s = await state(); + assert.match(s.out["other.yml"], /say third/); + assert.deepEqual(YAML.parse(s.out["example.yml"]).args.map((a) => [a.name, a["custom-arg"]]), [["third", "third"], ["second", "second"], ["first", "first"]]); + await act((a) => { a.redo(); a.refresh(); }); + assert.match((await state()).out["other.yml"], /say typed/); + }); + + await scenario("undo and redo persist both the resulting draft and layout", async ({ act, state, page }) => { + await page.waitForTimeout(1700); + await act((a) => { + window.__messages.length = 0; + const field = document.querySelector('.cb-command [data-f="description"]'); + field.value = "persist me"; + a.onInput({ target: field }); + a.moveNodeBy(field.closest(".drawflow-node").id.slice(5), 100, 100); + a.refresh(); + }); + await page.waitForTimeout(1700); + const changedLayout = (await state()).messages.filter((m) => m.type === "layout").at(-1).layout; + await act((a) => { window.__messages.length = 0; a.undo(); }); + await page.waitForTimeout(1700); + let s = await state(); + assert.equal(s.changes, 0); + assert.deepEqual(s.messages.find((m) => m.type === "draft").draft, {}); + assert.notDeepEqual(s.messages.find((m) => m.type === "layout").layout, changedLayout); + await act((a) => { window.__messages.length = 0; a.redo(); }); + await page.waitForTimeout(1700); + s = await state(); + assert.match(s.messages.find((m) => m.type === "draft").draft.files["example.yml"], /persist me/); + assert.deepEqual(s.messages.find((m) => m.type === "layout").layout, changedLayout); + }); +} finally { await browser.close(); } diff --git a/tests/browser/editor-ui.mjs b/tests/browser/editor-ui.mjs new file mode 100644 index 0000000..0f50a66 --- /dev/null +++ b/tests/browser/editor-ui.mjs @@ -0,0 +1,434 @@ +// Drives the editor in Chromium against a fake proxy. Needs the wiki dev server (8080) and the relay (wrangler dev, 8787). +// Run: node tests/browser/editor-ui.mjs [screenshot dir] +import { chromium } from "/usr/lib/node_modules/omniroute/node_modules/playwright-core/index.mjs"; +import { fakeProxy } from "./fake-proxy.mjs"; +import { readFileSync } from "node:fs"; + +const SHOTS = process.argv[2]; +const D = new URL("../fixtures/", import.meta.url); +const scripts = Object.fromEntries(["docs-examples-0.yml", "docs-examples-1.yml", "docs-examples-2.yml", "docs-examples-4.yml"].map((f) => [f, readFileSync(new URL(f, D), "utf8")])); +scripts["docs-examples-2.yml"] = scripts["docs-examples-2.yml"].replace("aliases: [eco]", "aliases: [eco, money]"); +const sendP = (m) => proxy.send(m); +// a stand-in for the proxy: it keeps the draft, holds staged changes until confirm() is called, and counts pings +const servers = { "proxy-1": { location: "VELOCITY", platform: "VELOCITY" }, lobby: { location: "BACKEND", platform: "PAPER" } }; +let draft = {}, staged = null, pings = 0; +const state = () => sendP({ type: "state", scripts, servers, draft, pending: staged?.code ?? null }); +const confirm = () => { + const results = {}; + for (const [f, y] of Object.entries(staged.files)) { scripts[f] = y; results[f] = []; } + for (const f of staged.deletions) { delete scripts[f]; results[f] = []; } + staged = null; draft = {}; + sendP({ type: "applied", results, warning: null }); + state(); +}; +const proxy = await fakeProxy((m) => { + if (m.type === "hello") state(); + if (m.type === "draft") draft = m.draft; + if (m.type === "ping") pings++; + if (m.type === "stage") { staged = { code: "k3m9x2", files: m.files, deletions: m.deletions }; sendP({ type: "staged", errors: {}, code: staged.code }); } + if (m.type === "unstage") { staged = null; state(); } +}); + +let fails = 0; +const check = (n, c, x = "") => { console.log((c ? "PASS " : "FAIL ") + n + (c ? "" : " " + x)); if (!c) fails++; }; +const browser = await chromium.launch({ executablePath: process.env.HOME + "/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome" }); +const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); +const errors = []; +page.on("pageerror", (e) => errors.push(e.message)); +page.on("console", (m) => { if (process.env.DEBUG && /error|warn/i.test(m.type())) console.log("CONSOLE", m.text()); }); +await page.goto(`http://localhost:8080/editor/#${proxy.link}`); +await page.waitForFunction(() => document.querySelectorAll(".drawflow-node").length > 5, null, { timeout: 15000 }); +await page.waitForTimeout(500); +const app = () => page.evaluate(() => { const a = Alpine.$data(document.querySelector(".ed")); return { changes: a.changes(), out: a.out, scripts: a.scripts, blocking: a.blocking().map((p) => ({ script: p.script, text: p.text })) }; }); +const shot = (n) => SHOTS && page.screenshot({ path: `${SHOTS}/${n}.png` }); + +const view = (loc, dx = 200, dy = 150) => loc.evaluate((n, [dx, dy]) => { const a = Alpine.$data(document.querySelector(".ed")), r = a.rectOf(n.id.slice(5)); a.centerOn(r.x + dx, r.y + dy, 1); }, [dx, dy]); +const blank = async () => { await page.evaluate(() => Alpine.$data(document.querySelector(".ed")).centerOn(-5000, -5000)); }; +await page.evaluate(() => document.querySelector("[x-data*=cookie], .cookie, #cookie-banner")?.remove()); + +let s = await app(); +check("loads without changes", s.changes === 0, JSON.stringify(s.blocking) + " " + Object.keys(s.out).filter((f) => s.out[f] !== s.scripts?.[f])); +check("every script has a Defaults node", await page.locator(".cb-defaults").count() === 4); +check("Defaults show run as PLAYER", await page.locator('.cb-defaults [data-f="d-runas"][value="PLAYER"]:checked').count() === 1); +check("checks player is a picker", await page.locator('.cb-defaults [data-f="d-player"]').evaluateAll((els) => els.map((e) => e.dataset.v)).then((v) => v.includes("player"))); +check("a connected backend shows the software it runs", await page.locator('.cb-server:has(.nk:text-is("lobby")) .srv-k').first().textContent() === "paper"); +check("server node shows its id as the title", await page.locator('.cb-server .nk', { hasText: "proxy-1" }).count() > 0); +// wires that run through a node they don't belong to, and nodes on top of each other +const crossings = () => page.evaluate(() => { + const nodes = [...document.querySelectorAll("#ed-canvas .drawflow-node")].map((n) => ({ id: n.id, r: n.getBoundingClientRect() })); + let wires = 0, overlaps = 0; + const bad = []; + for (const c of document.querySelectorAll("#ed-canvas svg.connection")) { + const ends = [...c.classList].filter((k) => k.startsWith("node_")).map((k) => "node-" + k.split("-")[1]); + const path = c.querySelector(".main-path"), len = path.getTotalLength(), m = path.getScreenCTM(); + const hit = new Set(); + for (let t = 0.08; t < 0.92; t += 0.04) { + const p = path.getPointAtLength(len * t), x = p.x * m.a + m.e, y = p.y * m.d + m.f; + for (const n of nodes) if (!ends.includes(n.id) && x > n.r.left + 2 && x < n.r.right - 2 && y > n.r.top + 2 && y < n.r.bottom - 2) hit.add(n.id); + } + wires += hit.size; + if (hit.size) bad.push(ends.join(">") + " through " + [...hit].join(",")); + } + for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i].r, b = nodes[j].r; + if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) { overlaps++; bad.push(nodes[i].id + " on " + nodes[j].id); } + } + return { wires, overlaps, bad: bad.slice(0, 6) }; +}); +const cx = await crossings(); +check("first layout: no overlapping nodes, no wire through a node", cx.overlaps === 0 && cx.wires === 0, JSON.stringify(cx)); +check("server node is fixed", await page.locator('.cb-server input[type="text"]').count() === 0); + +const def = page.locator(".cb-defaults").filter({ has: page.locator('[data-f="d-runas"][value="PLAYER"]:checked') }); +await view(def); +await def.locator('label:has([value="OPERATOR"])').click(); +s = await app(); +check("run-as change reaches YAML", /run-as: OPERATOR/.test(s.out["docs-examples-1.yml"])); + +// saving: staged on the proxy, written only after the confirm command +await page.locator(".ed-save").click(); +await page.locator(".ed-confirm").waitFor({ state: "visible" }); +check("Save shows the confirm command", (await page.locator(".ed-confirm code").textContent()) === "/cb editor confirm k3m9x2" && staged !== null); +check("nothing is written before the confirm", !scripts["docs-examples-1.yml"].includes("OPERATOR")); +await page.waitForTimeout(300); +await shot("confirm"); +const before = Object.keys(staged.files).length; +confirm(); +await page.locator(".ed-confirm").waitFor({ state: "hidden" }); +s = await app(); +check("after the confirm the changes are saved", s.changes === 0 && scripts["docs-examples-1.yml"].includes("OPERATOR") && before > 0, `${s.changes} left`); +// a change after Save takes the staged set back +const lobbyDesc = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="lobby"]') }).locator('[data-f="description"]'); +await view(lobbyDesc.locator("xpath=ancestor::div[contains(@class,'drawflow-node')]"), 200, 200); +await lobbyDesc.fill("Draft text"); +await page.locator(".ed-save").click(); +await page.locator(".ed-confirm").waitFor({ state: "visible" }); +await lobbyDesc.fill("Draft text, changed after Save"); +await page.waitForTimeout(300); +check("changing something after Save cancels the waiting code", staged === null && !(await page.locator(".ed-confirm").isVisible())); +// the unsaved change comes back after reopening the editor +await page.waitForTimeout(2000); +check("unsaved changes are kept on the proxy", JSON.stringify(draft).includes("Draft text, changed after Save")); +await page.reload(); +await page.waitForFunction(() => document.querySelectorAll(".drawflow-node").length > 5, null, { timeout: 15000 }); +await page.waitForTimeout(500); +await page.evaluate(() => document.querySelector("[x-data*=cookie], .cookie, #cookie-banner")?.remove()); +// a draft could have been left through a leaked link, so it's offered, not laid over the scripts on its own +check("reopening the editor offers them without applying", (await app()).changes === 0 && await page.locator(".ed-notes button", { hasText: "Review them" }).count() === 1); +await page.locator(".ed-notes button", { hasText: "Review them" }).click(); +check("reviewing restores them", await page.locator('.cb-command [data-f="description"]').evaluateAll((e) => e.some((x) => x.value === "Draft text, changed after Save")) && (await app()).changes === 1); +await page.locator(".ed-notes button:visible", { hasText: "Discard" }).click(); +check("Discard drops them", (await app()).changes === 0); + + +// aliases: a list, each row edited in place +const al = page.locator("#" + await page.locator(".cb-alias").filter({ has: page.locator('.tag[data-v="money"]') }).getAttribute("id")); +await view(al); +check("one Aliases node holds both aliases", await al.locator(".tag").count() === 2); +await al.locator(".tag-in").click(); +await page.keyboard.type("cash "); +s = await app(); +check("typed alias becomes a row in YAML", /aliases:.*cash/.test(s.out["docs-examples-2.yml"])); +await page.keyboard.type("Bad!"); +await page.keyboard.press("Enter"); +s = await app(); +check("invalid alias is marked on its row", await al.locator('.tag.bad').count() === 1 && s.blocking.length > 0); +await al.locator('.tag[data-v="Bad!"] input').fill(""); +await al.locator(".tag-in").focus(); +await al.locator('.tag[data-v="money"] input').fill("coins"); +s = await app(); +check("an alias row is edited in place", /aliases: \[eco, coins, cash\]/.test(s.out["docs-examples-2.yml"])); +await al.locator('.tag[data-v="coins"] input').fill("money"); +await al.locator('.tag[data-v="cash"]').hover(); +await al.locator('.tag[data-v="cash"] .tag-x').click(); +s = await app(); +check("emptying and removing rows leaves YAML", /aliases: \[eco, money\]/.test(s.out["docs-examples-2.yml"]) && !s.blocking.length, JSON.stringify(s.blocking)); +await shot("nodes"); + +// command text with argument pieces, dragged with the mouse +const at = async (loc) => { const b = await loc.boundingBox(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; }; +const runId = await page.locator(".cb-run").filter({ has: page.locator('.ph[data-arg="amount"]') }).getAttribute("id"); +const run = page.locator("#" + runId); +await view(run); +check("placeholders render as pieces without a remove button", await run.locator(".ph").count() === 2 && await run.locator(".ph button").count() === 0); +// out to the remove zone +let p0 = await at(run.locator('.ph[data-arg="amount"]')); +await page.mouse.move(p0.x, p0.y); await page.mouse.down(); await page.mouse.move(p0.x + 30, p0.y + 40, { steps: 4 }); +const bin = await at(page.locator(".ed-trash")); +await page.mouse.move(bin.x, bin.y, { steps: 8 }); +check("the remove zone lights up under the piece", await page.locator(".ed-trash.hot").count() === 1); +await shot("trash"); +await page.mouse.up(); +s = await app(); +check("dropping into the remove zone removes the piece", /command: "?eco give \$\{player\}"?\s*$/m.test(s.out["docs-examples-2.yml"]), s.out["docs-examples-2.yml"].match(/command:.*/)?.[0]); +// back in, aimed at the middle of a word: it goes between words and the text makes room while dragging +p0 = await at(run.locator('.pill[data-arg="amount"]')); +const word = await run.locator(".cmd").boundingBox(); +await page.mouse.move(p0.x, p0.y); await page.mouse.down(); +await page.mouse.move(word.x + 14, word.y + word.height / 2, { steps: 10 }); +check("dragging shows where the piece goes", await run.locator(".cmd .ph.preview").count() === 1); +await shot("drag-preview"); +await page.mouse.up(); +s = await app(); +check("a piece never splits a word", /command: "?(\$\{amount\} eco|eco \$\{amount\}) give \$\{player\}"?\s*$/m.test(s.out["docs-examples-2.yml"]), s.out["docs-examples-2.yml"].match(/command:.*/)?.[0]); +// a piece dragged out and dropped on nothing goes back +p0 = await at(run.locator('.ph[data-arg="amount"]')); +await page.mouse.move(p0.x, p0.y); await page.mouse.down(); await page.mouse.move(p0.x + 200, p0.y - 150, { steps: 6 }); await page.mouse.up(); +check("a piece dropped on nothing goes back", await run.locator('.ph[data-arg="amount"]').count() === 1); +// moved to the end +p0 = await at(run.locator('.ph[data-arg="amount"]')); +await page.mouse.move(p0.x, p0.y); await page.mouse.down(); await page.mouse.move(word.x + word.width - 10, word.y + word.height / 2, { steps: 10 }); +await page.mouse.up(); +s = await app(); +check("a piece moves within the command", /eco give \$\{player\} \$\{amount\}/.test(s.out["docs-examples-2.yml"]), s.out["docs-examples-2.yml"].match(/command:.*/)?.[0]); +await run.locator(".cmd").click({ position: { x: 12, y: 14 } }); +await page.keyboard.press("End"); +await page.keyboard.type(" ${nope}"); +await page.locator("#ed-canvas").click({ position: { x: 5, y: 5 } }); +check("typed placeholder becomes a marked piece", await run.locator('.ph.bad[data-arg="nope"]').count() === 1); +await run.locator('.ph[data-arg="nope"]').hover(); +check("hover shows the reason", /no argument nope/.test(await page.locator(".ed-tip").textContent())); +await page.locator(".ed-savewrap").hover(); +check("save explains why it's blocked", /Fix 1 problem/.test(await page.locator(".ed-tip").textContent()), await page.locator(".ed-tip").textContent()); +await shot("errors"); +p0 = await at(run.locator('.ph[data-arg="nope"]')); +await page.mouse.move(p0.x, p0.y); await page.mouse.down(); await page.mouse.move(p0.x + 30, p0.y + 40, { steps: 4 }); +await page.mouse.move(...Object.values(await at(page.locator(".ed-trash"))), { steps: 6 }); await page.mouse.up(); +check("notes stay empty for pinned problems", !(await page.locator(".ed-notes").isVisible())); + +// a step that calls another script shows the called name as a fixed piece +await view(run, 300, 60); +await run.locator(".output.output_2").click({ force: true }); +await page.locator(".ed-pop .ed-mi", { hasText: "/lobby" }).click(); +check("a calling step shows the called script as a piece", await run.locator(".cmd .callw").textContent() === "/lobby"); +await shot("calling"); +s = await app(); +// "eco" was an alias of the script itself, so the called name takes its place +check("the call keeps its arguments in the text", /command: "?lobby give/.test(s.out["docs-examples-2.yml"]), s.out["docs-examples-2.yml"].match(/command:.*/)?.[0]); +await page.keyboard.press("Control+z"); +await page.keyboard.press("Control+z"); + +// description wraps downwards +const cmdNode = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="eco-give"]') }); +const h0 = (await cmdNode.locator('[data-f="description"]').boundingBox()).height; +await cmdNode.locator('[data-f="description"]').click(); +await page.keyboard.type(" and a lot more words so the description needs a second line to show all of it"); +check("description grows downwards", (await cmdNode.locator('[data-f="description"]').boundingBox()).height > h0 + 10); +await page.keyboard.press("Escape"); + +// group toggle +const grp = page.locator(".grp").filter({ has: page.locator('.grp-t[value="/eco-give"]') }); +await grp.locator(".grp-h .sw").click(); +s = await app(); +check("group off disables its script", /enabled: false/.test(s.out["docs-examples-2.yml"])); +await grp.locator(".grp-h .sw").click(); + +// a loose Wait dropped onto the wire between a command and its step goes in between +const ecoCmd = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="eco-give"]') }); +await view(ecoCmd, 600, 60); +const cb = await ecoCmd.boundingBox(); +await page.mouse.click(cb.x + cb.width + 40, cb.y - 120, { button: "right" }); +await page.locator(".ed-pop > .ed-mw > .ed-mi", { hasText: "Step" }).hover(); +await page.locator(".ed-sub .ed-mi", { hasText: "Wait" }).click(); +const wait = page.locator(".cb-wait.is-sel"); +const wh = await wait.locator(".nh").boundingBox(); +const then = await ecoCmd.locator(".output.output_1").boundingBox(); +await page.mouse.move(wh.x + 60, wh.y + 15); await page.mouse.down(); +await page.mouse.move(then.x + 60, then.y - 20, { steps: 12 }); +check("the wire under a dragged Wait lights up", await page.locator("svg.connection.insert-at").count() === 1); +await shot("insert"); +await page.mouse.up(); +s = await app(); +check("dropping it there puts the Wait between them", /command: .*eco give.*\n\s+delay: 1s/.test(s.out["docs-examples-2.yml"]), s.out["docs-examples-2.yml"].split("commands:")[1]); +await page.keyboard.press("Control+z"); +await page.keyboard.press("Control+z"); +await page.keyboard.press("Control+z"); + +// durations: suggestions in the editor's menu +const def2 = page.locator(".cb-defaults").filter({ has: page.locator('[data-f="d-runas"][value="OPERATOR"]:checked') }); +await view(def2, 200, 150); +await def2.locator('[data-f="d-delay"]').click(); +check("a duration field offers durations", await page.locator(".ed-pop .ed-mi", { hasText: "500ms" }).count() === 1); +await page.keyboard.type("1"); +check("typing narrows them down", await page.locator(".ed-pop .ed-mi", { hasText: "500ms" }).count() === 0 && await page.locator(".ed-pop .ed-mi", { hasText: "1m" }).count() === 1); +await page.locator(".ed-pop .ed-mi", { hasText: "1m" }).click(); +check("picking one fills the field", await def2.locator('[data-f="d-delay"]').inputValue() === "1m"); +await def2.locator('[data-f="d-delay"]').fill(""); +await page.keyboard.press("Escape"); +// checks player: closed while unused, and picking an argument makes it required +await def2.locator('[data-f="d-player"]').click({ force: true }); +check("checks player can't be opened while it's unused", await page.locator(".ed-pop .ed-mi", { hasText: "who ran it" }).count() === 0); + +// groups from a selection +const alert = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="alert"]') }); +const lobby = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="lobby"]') }); +const groupsBefore = await page.locator(".grp").count(); +await view(alert, 100, 10); +await alert.locator(".nh").click({ position: { x: 120, y: 15 } }); +await page.evaluate(() => Alpine.$data(document.querySelector(".ed")).fit()); +await lobby.locator(".nh .nk").click({ modifiers: ["Control"], force: true }); +await page.keyboard.press("Control+g"); +check("Ctrl+G groups two selected scripts", await page.locator(".grp").count() === groupsBefore - 1 && await page.locator(".grp:not(.solo) .grp-n").first().textContent() === "2 scripts"); +await page.keyboard.press("Escape"); +await page.keyboard.press("Control+z"); +check("undo restores the groups", await page.locator(".grp").count() === groupsBefore); + +// menus +const box = await page.locator("#ed-canvas").boundingBox(); +await blank(); +await page.mouse.click(box.x + 600, box.y + 400, { button: "right" }); +check("context menu opens", await page.locator(".ed-pop").isVisible()); +await page.locator(".ed-pop > .ed-mw > .ed-mi", { hasText: "Step" }).hover(); +await shot("ctx-add"); +await page.locator(".ed-sub .ed-mi", { hasText: "Execute" }).click(); +check("Execute added from context menu", await page.locator(".cb-run .nh.has-err").count() === 1); +await page.mouse.click(box.x + 900, box.y + 300, { button: "right" }); +await page.locator(".ed-pop .ed-mi-l").getByText("Server", { exact: true }).hover(); +await shot("ctx-servers"); +// safe triangle: heading diagonally into the submenu across other items keeps it open +const srvItem = await page.locator(".ed-pop .ed-mi-l").getByText("Server", { exact: true }).boundingBox(); +const sub = await page.locator(".ed-mw.open > .ed-sub").boundingBox(); +await page.mouse.move(srvItem.x + 20, srvItem.y + 8); +await page.mouse.move(sub.x + 10, sub.y + sub.height - 10, { steps: 12 }); +check("safe triangle keeps the submenu", await page.locator(".ed-mw.open .ed-mi-l", { hasText: "lobby" }).count() === 1); +await page.locator(".ed-sub .ed-mi", { hasText: "lobby" }).click(); +check("server added from the menu", await page.locator('.cb-server [data-f="id"][value="lobby"]').count() > 0); + +await page.locator(".ed-addbtn").click(); +await page.locator(".ed-pop .ed-mi-l").getByText("Server", { exact: true }).hover(); +await shot("add-servers"); +await page.locator('.ed-custom input').fill("hub-2"); +await page.locator('.ed-custom button[value="VELOCITY"]').click(); +check("custom id adds a proxy server", await page.locator('.cb-server:has([data-f="id"][value="hub-2"]):has([data-f="location"][value="VELOCITY"])').count() === 1); + +// a new command comes with its Defaults wired +await page.locator(".ed-addbtn").click(); +await page.locator(".ed-pop .ed-mi", { hasText: "Command" }).click(); +s = await app(); +const fresh = s.out["new-command.yml"] || ""; +check("new command has Defaults with a target", /defaults:[\s\S]*execute:[\s\S]*proxy-1/.test(fresh) && !s.blocking.some((b) => b.script === "new-command"), JSON.stringify(s.blocking)); +check("a new command gets exactly one group", await page.locator(".grp .grp-t").evaluateAll((e) => e.filter((x) => x.value === "/new-command").length) === 1); + +// copy / paste +await view(alert, 100, 10); +await alert.locator(".nh").click({ position: { x: 120, y: 15 } }); +await page.keyboard.press("Control+c"); +await page.keyboard.press("Control+v"); +s = await app(); +check("pasted Command is a new script", Object.keys(s.out).includes("alert-copy.yml")); +const hb = await alert.locator(".nh").boundingBox(); +await page.mouse.click(hb.x + 120, hb.y + 15, { button: "right" }); +await page.locator(".ed-pop .ed-mi-l").getByText("Group", { exact: true }).hover(); +await shot("ctx-node"); +check("node menu shows the group, with the current one checked", await page.locator(".ed-mw.open .ed-sub .ed-mi.checked").count() === 1 && await page.locator(".ed-mw.open .ed-sub .ed-mi", { hasText: "New group" }).count() === 1); +await page.keyboard.press("Escape"); + +// renaming a command keeps its single group +const nc = page.locator(".cb-command").filter({ has: page.locator('[data-f="name"][value="new-command"]') }); +await view(nc, 100, 100); +await nc.locator('[data-f="name"]').fill(""); +await nc.locator('[data-f="name"]').type("alert"); +await nc.locator('[data-f="name"]').fill(""); +await nc.locator('[data-f="name"]').type("renamed"); +check("renaming through a taken name keeps one group each", await page.evaluate(() => { + const t = [...document.querySelectorAll(".grp .grp-t")].map((x) => x.value); + return t.filter((v) => v === "/renamed").length === 1 && t.filter((v) => v === "/alert").length === 1; +}), await page.evaluate(() => [...document.querySelectorAll(".grp .grp-t")].map((x) => x.value).join(","))); +await page.keyboard.press("Escape"); + +// pickers +const arg = page.locator(".cb-arg").filter({ has: page.locator('[data-f="name"][value="message"]') }); +await view(arg, 100, 100); +await arg.locator('[data-f="type"]').click(); +await shot("type-menu"); +check("type picker lists types by family", await page.locator(".ed-pop .ed-pop-s", { hasText: "Numbers" }).count() === 1); +await page.locator(".ed-pop .ed-mi", { hasText: "GREEDY_STRING" }).click(); +check("picking a type reaches YAML", /type: GREEDY_STRING/.test((await app()).out["docs-examples-0.yml"])); +const alertRun = page.locator(".cb-run").filter({ has: page.locator('.ph[data-arg="message"]') }).first(); +check("unwired step settings show what the Defaults give", await alertRun.locator('[data-port="run as"] .inh').textContent() === "console"); + +// sort +await page.locator(".ed-tool", { hasText: "Sort" }).click(); +const cs = await crossings(); +check("Sort: no overlapping nodes, no wire through a node", cs.overlaps === 0 && cs.wires === 0, JSON.stringify(cs)); +await shot("sorted"); + +// yaml panel +await page.locator(".ed-tool", { hasText: "YAML" }).click(); +await page.locator(".ed-yaml").waitFor({ state: "visible" }); +await page.waitForTimeout(350); // the panel slides in +const grip = await page.locator(".ed-yaml-grip").boundingBox(), w0 = (await page.locator(".ed-yaml").boundingBox()).width; +await page.mouse.move(grip.x + 4, grip.y + 300); await page.mouse.down(); await page.mouse.move(grip.x - 150, grip.y + 300, { steps: 5 }); await page.mouse.up(); +check("the YAML panel is dragged wider", (await page.locator(".ed-yaml").boundingBox()).width > w0 + 100); +const code = await page.locator(".ed-yaml-code").boundingBox(); +await page.mouse.move(code.x + 20, code.y + 20); await page.mouse.down(); await page.mouse.move(code.x + 200, code.y + 80, { steps: 5 }); await page.mouse.up(); +check("text selected in the YAML stays selected", await page.evaluate(() => { const t = document.querySelector(".ed-yaml-ta"); return t.selectionEnd - t.selectionStart > 5; })); +// editing the YAML changes the nodes +await page.locator(".ed-file", { hasText: "docs-examples-2.yml" }).click(); +const ta = page.locator(".ed-yaml-ta"); +const yaml0 = await ta.inputValue(); +await ta.fill(yaml0.replace("silent: false", "silent: true")); +await page.locator(".ed-yaml-main header").click(); +await page.waitForTimeout(100); +const ecoSilent = () => page.locator('.cb-command').filter({ has: page.locator('[data-f="name"][value="eco-give"]') }).locator('[data-f="silent"]').isChecked(); +check("editing the YAML updates the node", await ecoSilent()); +await ta.fill(yaml0.replace("version: 4", "version: [4")); +await page.waitForTimeout(900); +check("broken YAML shows an error and leaves the nodes", await page.locator(".ed-yaml-err").isVisible() && await ecoSilent()); +// linting: a wrong value is underlined and explained on its line +await ta.fill(yaml0.replace("silent: false", "silent: maybe")); +check("a wrong value is underlined", await page.locator(".ed-yaml-lint .lint-e", { hasText: "maybe" }).count() === 1); +check("its line number says why", /true or false/.test(await page.locator(".ed-yaml-n span.le").first().getAttribute("data-err"))); +await page.waitForTimeout(300); +await shot("yaml-lint"); +// completion: values after a key, keys where a key goes +await ta.fill(yaml0); +await ta.evaluate((t) => { const i = t.value.indexOf("silent: false") + "silent: ".length; t.focus(); t.setSelectionRange(i, i + 5); }); +await page.keyboard.press("Backspace"); +await page.keyboard.press("Control+Space"); +check("after a key the values are offered", (await page.locator(".ed-ycmp button > span").allTextContents()).join(",") === "true,false"); +await page.waitForTimeout(300); +await shot("yaml-complete"); +await page.keyboard.press("Enter"); +check("Enter takes the first suggestion", (await ta.inputValue()).includes("silent: true")); +await ta.evaluate((t) => { const i = t.value.indexOf(" enabled: ", t.value.indexOf("permissions:")); t.focus(); t.setSelectionRange(i, i); }); +await page.keyboard.type(" si\n".slice(0, 4)); +check("keys already there aren't offered again", !(await page.locator(".ed-ycmp button > span").allTextContents()).includes("silent")); +await page.keyboard.press("Escape"); +await ta.fill(yaml0); +await page.locator(".ed-yaml-main header").click(); +await page.waitForTimeout(300); +check("the header names the file picked in the list", (await page.locator(".ed-yaml-main header span").textContent()) === "scripts/" + (await page.locator(".ed-file.on").getAttribute("title")).split(" (")[0], + (await page.locator(".ed-yaml-main header span").textContent()) + " vs " + await page.locator(".ed-file.on").getAttribute("title")); +await shot("yaml"); +await page.locator(".ed-tool", { hasText: "YAML" }).click(); +await page.locator(".ed-tool", { hasText: "YAML" }).click(); +check("YAML panel is highlighted", await page.locator(".ed-yaml .y-key").count() > 3); +check("YAML panel lists the files with what saving does", await page.locator(".ed-file.changed").count() > 0 && await page.locator(".ed-file.new").count() > 0, await page.locator(".ed-files").innerText()); +await page.locator(".ed-file.new").first().click(); +check("picking a file shows it", (await page.locator(".ed-yaml-main header span").textContent()).includes((await page.locator(".ed-file.new .ed-file-n").first().textContent()))); +await shot("yaml"); +await page.locator(".ed-tool", { hasText: "YAML" }).click(); + +// shortcuts +await page.keyboard.press("h"); +check("H picks the grab tool", await page.evaluate(() => document.getElementById("ed-canvas").classList.contains("ed-grab"))); +await page.keyboard.press("v"); +await alert.locator('[data-f="enabled"]').evaluate((i) => i.focus()); +const z0 = await page.evaluate(() => Alpine.$data(document.querySelector(".ed")).zoom); +await page.keyboard.press("Control+0"); +check("shortcuts work while a switch has focus", await page.evaluate(() => Alpine.$data(document.querySelector(".ed")).zoom) === 100 && z0 !== undefined); +await view(alert, 100, 10); +await alert.locator(".nh").click({ position: { x: 120, y: 15 } }); +const x0 = (await alert.boundingBox()).x; +await page.keyboard.press("ArrowRight"); +check("arrow keys nudge", Math.round((await alert.boundingBox()).x - x0) === 10); + +await page.evaluate(() => document.documentElement.classList.remove("dark")); +await view(ecoCmd, 300, 200); +await shot("light"); +check("no page errors", !errors.length, errors.join(" | ")); +await browser.close(); +proxy.close(); +console.log(fails ? `${fails} failed` : "all passed"); +process.exit(fails ? 1 : 0); diff --git a/tests/browser/fake-proxy.mjs b/tests/browser/fake-proxy.mjs new file mode 100644 index 0000000..e390597 --- /dev/null +++ b/tests/browser/fake-proxy.mjs @@ -0,0 +1,38 @@ +// Plays the proxy's end of a relay session for the browser tests, encrypted the way RelayChannel.java does it. +import crypto from "node:crypto"; + +const enc = (t) => new TextEncoder().encode(t); +const b64url = (b) => Buffer.from(b).toString("base64url"); + +export async function fakeProxy(onMessage) { + const id = crypto.randomBytes(16).toString("hex"), secret = crypto.randomBytes(32); + const ikm = await crypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveKey"]); + const key = await crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: enc("commandbridge-relay-v1"), info: enc("aes-256-gcm") }, + ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + const ws = new WebSocket(`ws://localhost:8787/api/editor/${id}/ws?role=proxy`, { headers: { Authorization: "Bearer cb-dump-upload-v1" } }); + let seq = 0, conn = "", queue = Promise.resolve(); + const proxy = { + id, + link: `${id}:${secret.toString("base64url")}`, + // in order, like the real proxy + send(msg) { + queue = queue.then(async () => { + const s = ++seq, iv = crypto.randomBytes(12); + const box = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc(`proxy\n${conn}\n${s}`) }, key, enc(JSON.stringify(msg))); + ws.send(JSON.stringify({ conn, seq: s, iv: b64url(iv), box: b64url(box) })); + }); + return queue; + }, + close: () => ws.close(), + raw: ws, + }; + ws.onmessage = async (e) => { + const env = JSON.parse(e.data); + conn = env.conn; + const body = await crypto.subtle.decrypt({ name: "AES-GCM", iv: Buffer.from(env.iv, "base64url"), additionalData: enc(`editor\n${env.conn}\n${env.seq}`) }, + key, Buffer.from(env.box, "base64url")); + onMessage(JSON.parse(new TextDecoder().decode(body))); + }; + await new Promise((r) => (ws.onopen = r)); + return proxy; +} diff --git a/tests/browser/setup-ui.mjs b/tests/browser/setup-ui.mjs new file mode 100644 index 0000000..93355b5 --- /dev/null +++ b/tests/browser/setup-ui.mjs @@ -0,0 +1,201 @@ +// Drives the setup page in Chromium against a fake proxy. Needs the wiki dev server (8080) and the relay (wrangler dev, 8787). +// Run: node tests/browser/setup-ui.mjs [screenshot dir] +import { chromium } from "/usr/lib/node_modules/omniroute/node_modules/playwright-core/index.mjs"; +import crypto from "node:crypto"; +import { fakeProxy } from "./fake-proxy.mjs"; + +const SHOTS = process.argv[2]; + +// a stand-in for the proxy: lobby is connected and keeps its settings; the others get codes once the setup is confirmed +const current = { serverId: "proxy-1", endpointType: "WEBSOCKET", port: 8765, bindHost: "0.0.0.0", + redis: { host: "127.0.0.1", port: 6379, username: "" }, tlsMode: "TOFU", keystore: { path: "", type: "PKCS12" } }; +const servers = [ + { id: "lobby", address: "127.0.0.1:30066", connected: true, platform: "PAPER", version: null }, + { id: "survival", address: "10.0.0.12:25565", connected: false, platform: null, version: null }, +]; +let plan = null, codes = {}, paired = {}, staged = null, applied = false, finished = false, plans = []; +const state = () => sendP({ type: "state", current, servers, saved: null, plan, codes, paired, staged, applied }); +const sendP = (m) => proxy.send(m); +const confirm = () => { + applied = true; staged = null; + for (const s of plan.servers) if (!paired[s.id]) codes[s.id] = "7KQM-4XR2-" + s.id.slice(0, 2).toUpperCase().replace(/[^2-9A-HJKMNP-Z]/g, "Z"); + sendP({ type: "applied", restartNeeded: true, errors: [] }); + state(); +}; +const proxy = await fakeProxy((m) => { + if (m.type === "hello") state(); + if (m.type === "plan") { + plans.push(m.plan); + if (m.plan.address === "0.0.0.0") { sendP({ type: "plan-error", problems: ["Servers can't reach 0.0.0.0. Use the address they connect to."] }); return; } + plan = m.plan; paired = { lobby: { platform: "PAPER", version: null } }; + state(); + } + if (m.type === "stage") { staged = "q8n2fl"; sendP({ type: "staged", code: staged }); } + if (m.type === "unstage") { staged = null; state(); } + if (m.type === "finish") finished = true; + if (m.type === "editor") sendP({ type: "editor", url: `http://localhost:8080/editor/#${"a".repeat(32)}:${"b".repeat(43)}` }); +}); + +let fails = 0; +const check = (n, c, x = "") => { console.log((c ? "PASS " : "FAIL ") + n + (c ? "" : " " + x)); if (!c) fails++; }; +const browser = await chromium.launch({ executablePath: process.env.HOME + "/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome" }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); +const errors = []; +page.on("pageerror", (e) => errors.push(e.message)); +page.on("console", (m) => { if (process.env.DEBUG && /error|warn/i.test(m.type())) console.log("CONSOLE", m.text()); }); +const url = `http://localhost:8080/setup/#${proxy.link}`; +await page.goto(url); +await page.evaluate(() => document.querySelector("[x-data*=cookie], .cookie, #cookie-banner")?.remove()); +const shot = (n) => SHOTS && page.screenshot({ path: `${SHOTS}/${n}.png` }); +const nodes = () => page.locator(".q:visible").count(); +const ask = () => page.locator(".ask:visible").first().textContent().catch(() => ""); +const enter = async () => { await page.keyboard.press("Enter"); await page.waitForTimeout(250); }; + +try { + await page.waitForFunction(() => [...document.querySelectorAll(".ask")].some((e) => e.offsetParent), null, { timeout: 15000 }); + check("starts with the name", (await ask()) === "What's this proxy called?", await ask()); + check("the secret leaves the address bar", !page.url().includes("#"), page.url()); + check("name is prefilled", await page.locator('[data-f="serverId"]').inputValue() === "proxy-1"); + let one = (await nodes()) === 1; + await shot("1-name"); + await enter(); + check("asks how servers connect", (await ask()) === "How do your servers connect to the proxy?", await ask()); + check("the default choice is marked", (await page.locator('[data-step="type"] .opt:has([value="WEBSOCKET"]) .def').count()) === 1); + check("a new screen slides in forward", await page.locator('[data-step="type"].in-fwd').count() === 1); + one &&= (await nodes()) === 1; + await enter(); + check("asks for the port", (await ask()) === "Which port do your servers connect to?", await ask()); + one &&= (await nodes()) === 1; + await enter(); + check("asks for the address", (await ask()) === "What address do your servers connect to?", await ask()); + one &&= (await nodes()) === 1; + + await page.locator('[data-f="address"]').fill("0.0.0.0"); + await enter(); + check("zero address is refused", (await page.locator(".setup-err:visible").textContent().catch(() => "")).includes("Servers can't reach 0.0.0.0"), + await page.locator(".setup-err").allTextContents()); + check("stays on the address", (await ask()) === "What address do your servers connect to?"); + const addressError = async (v) => { await page.locator('[data-f="address"]').fill(v); await enter(); return page.locator(".setup-err:visible").textContent().catch(() => ""); }; + check("a broken IP is refused", (await addressError("999.1.1.1")).includes("0 to 255")); + check("a protocol is refused", (await addressError("ws://play.example.net")).includes("ws://")); + check("a port in the address is refused", (await addressError("play.example.net:8765")).includes("port")); + check("spaces are refused", (await addressError("play example")).includes("isn't a valid address")); + await page.locator('[data-f="address"]').fill("play.example.net"); + await enter(); + check("asks about encryption", (await ask()) === "How should the connection be secured?", await ask()); + one &&= (await nodes()) === 1; + await enter(); + check("asks for the servers", (await ask()) === "Which servers run CommandBridge?", await ask()); + one &&= (await nodes()) === 1; + check("asks one question per screen", one); + check("finished steps show their answer", (await page.locator(".su-progress button.done").evaluateAll((b) => b.map((x) => x.title))).includes("Address: play.example.net"), + await page.locator(".su-progress button.done").evaluateAll((b) => b.map((x) => x.title))); + + // add servers Velocity doesn't have yet; the address starts as the proxy's, on the next free port + await page.locator(".srow-add").click(); + check("a new server gets the proxy's address", await page.locator('[data-f="new-address"]').inputValue() === "play.example.net:30067", + await page.locator('[data-f="new-address"]').inputValue()); + await page.locator('[data-f="new-id"]').fill("creative"); + await page.locator('[data-f="new-address"]').fill("10.0.0.13:99999"); + await enter(); + check("a bad server port is refused", (await page.locator(".setup-err:visible").textContent().catch(() => "")).includes("1 and 65535")); + await page.locator('[data-f="new-address"]').fill("10.0.0.13:25565"); + await enter(); + check("enter adds the server and opens the next one", await page.locator('[data-step="servers"] .srow-id', { hasText: "creative" }).count() === 1 && + await page.locator('[data-f="new-id"]').inputValue() === "" && await page.locator('[data-f="new-address"]').inputValue() === "play.example.net:30067", + JSON.stringify([await page.locator('.srow-id').allTextContents(), await page.locator('[data-f="new-id"]').inputValue().catch((e) => e.message.slice(0, 60)), await page.locator('[data-f="new-address"]').inputValue().catch(() => "-")])); + await shot("2-servers"); + await page.locator(".btn-next").click(); + await page.waitForFunction(() => [...document.querySelectorAll(".ask")].some((e) => e.offsetParent && e.textContent === "Ready to apply"), null, { timeout: 5000 }).catch(() => {}); + const sent = plans.at(-1); + check("enter keeps the default port", sent?.port === 8765, JSON.stringify(sent)); + check("the plan marks the added server", sent?.servers.some((s) => s.id === "creative" && s.addToVelocity) && sent.servers.some((s) => s.id === "lobby" && !s.addToVelocity), JSON.stringify(sent?.servers)); + check("an empty extra row isn't sent", !sent?.servers.some((s) => !s.id)); + check("asks to apply before pairing", (await ask()) === "Ready to apply", await ask()); + await page.waitForTimeout(300); + check("apply shows the confirm command", (await page.locator(".setup-confirm code").textContent().catch(() => "")) === "/cb setup confirm q8n2fl"); + check("no pairing code before the confirm", await page.locator(".pair-row code").count() === 0); + await shot("4-apply"); + + await page.reload(); + await page.waitForFunction(() => [...document.querySelectorAll(".ask")].some((e) => e.offsetParent), null, { timeout: 15000 }); + await page.waitForTimeout(300); + check("a reload keeps the link and the step", (await ask()) === "Ready to apply", await ask()); + + confirm(); + await page.waitForFunction(() => [...document.querySelectorAll(".ask")].some((e) => e.offsetParent && e.textContent === "Pair your servers"), null, { timeout: 5000 }).catch(() => {}); + await page.waitForTimeout(300); + check("the confirm leads to pairing", (await ask()) === "Pair your servers", await ask()); + check("restart note", (await page.locator(".setup-restart").textContent().catch(() => "")).includes("CommandBridge restarted with the new settings")); + check("no going back once applied", await page.locator(".setup-back:visible").count() === 0); + const cmds = await page.locator('[data-step="pairing"] .pair-row code').allTextContents(); + check("pairing shows a code per server", cmds.some((c) => c.startsWith("/cbc pair ") && c.includes("7KQM-4XR2-")) && + (await page.locator('[data-step="pairing"] .pair-row[data-id="survival"] code').count()) === 1 && (await page.locator('[data-step="pairing"] .pair-row[data-id="lobby"] code').count()) === 0, JSON.stringify(cmds)); + check("a waiting server says so", (await page.locator('[data-step="pairing"] .pair-row[data-id="survival"] .pair-state').textContent()).trim() === "waiting"); + await page.locator('[data-step="pairing"] .pair-row[data-id="survival"] .cmd button').click(); + check("copy leaves out the slash", (await page.evaluate(() => Alpine.$data(document.querySelector(".su")).copied)).startsWith("cbc pair ")); + check("a connected server gets a checkmark", await page.locator('[data-step="pairing"] .pair-row[data-id="lobby"] .pair-check:visible').count() === 1); + await shot("3-pairing"); + if (SHOTS) { + await page.evaluate(() => document.documentElement.classList.remove("dark")); + await page.waitForTimeout(500); + await shot("3-pairing-light"); + await page.evaluate(() => document.documentElement.classList.add("dark")); + } + + // something that isn't a code never reaches the page, it would end up in a console + const realCodes = { ...codes }; + codes.survival = "AAAA-BBBB-CC\nop attacker"; + state(); + await page.waitForTimeout(400); + check("a malformed code is not shown", await page.locator('[data-step="pairing"] .pair-row[data-id="survival"] code').count() === 0); + codes = realCodes; + state(); + await page.waitForTimeout(300); + + sendP({ type: "paired", id: "survival", platform: "PAPER", version: "1.21.4" }); + paired.survival = { platform: "PAPER", version: "1.21.4" }; delete codes.survival; + await page.waitForTimeout(400); + check("paired switches the server", await page.locator('[data-step="pairing"] .pair-row[data-id="survival"] .pair-state.ok').count() === 1); + check("paired shows the software", (await page.locator('[data-step="pairing"] .pair-row[data-id="survival"]').textContent()).includes("Paper 1.21.4")); + + await page.reload(); + await page.waitForFunction(() => [...document.querySelectorAll(".ask")].some((e) => e.offsetParent), null, { timeout: 15000 }); + await page.waitForTimeout(300); + check("reload lands on the same step", (await ask()) === "Pair your servers", await ask()); + check("reload keeps the code", (await page.locator('[data-step="pairing"] .pair-row[data-id="creative"] code').textContent().catch(() => "")).startsWith("/cbc pair ")); + + await page.locator(".setup-later").click(); + await page.waitForTimeout(500); + check("the end says the network is set up", (await ask()) === "Your network is set up", await ask()); + check("the proxy hears the setup is finished", finished); + check("the end still lists the unpaired code", await page.locator('[data-step="done"] .pair-row[data-id="creative"] code').count() === 1); + check("the end offers the editor and the docs", await page.locator(".setup-editor:visible").count() === 1 && await page.locator('a[href="/docs/"]:visible').count() === 1); + await shot("5-done"); + + sendP({ type: "editor", url: "javascript:alert(document.domain)" }); + await page.waitForTimeout(400); + check("a link that isn't the editor's is refused", page.url().startsWith("http://localhost:8080/setup/") && + (await page.locator('[data-step="done"] .setup-err:visible').textContent().catch(() => "")).includes("isn't the editor's")); + + await page.locator(".setup-editor").click(); + const opened = await page.waitForURL("**/editor/**", { timeout: 5000 }).then(() => true, () => false); + check("the editor button opens the link the proxy sends", opened, page.url()); + + // a new link pasted into the same tab only changes the #fragment; the page has to follow it + await page.goto(`http://localhost:8080/setup/#${"0".repeat(32)}:${crypto.randomBytes(32).toString("base64url")}`); + const ended = await page.waitForFunction(() => document.querySelector(".su-note-t")?.offsetParent && document.querySelector(".su-note-t").textContent === "Set up your network", null, { timeout: 8000 }).then(() => true, () => false); + check("a new link in the same tab is followed", ended, await page.locator(".su-note").innerText().catch(() => "")); + + // without a link the page says where to get one + await page.goto("http://localhost:8080/setup/"); + check("no link explains itself", (await page.locator(".setup-nolink").textContent().catch(() => "")).includes("/cb setup")); + check("no page errors", errors.length === 0, errors.join(" | ")); +} catch (e) { + check("ran to the end", false, e.message); +} + +await browser.close(); +proxy.close(); +console.log(fails ? `${fails} failed` : "all passed"); +process.exit(fails ? 1 : 0); diff --git a/tests/fixtures/docs-examples-0.yml b/tests/fixtures/docs-examples-0.yml new file mode 100644 index 0000000..a8f9b68 --- /dev/null +++ b/tests/fixtures/docs-examples-0.yml @@ -0,0 +1,38 @@ +version: 4 +name: alert +description: Broadcast an alert to all servers +enabled: true +aliases: [] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "lobby" + location: BACKEND + - id: "survival" + location: BACKEND + - id: "minigames" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: message + required: true + type: GREEDY_STRING + suggestions: [] + +commands: + - command: "say [Alert]: ${message}" diff --git a/tests/fixtures/docs-examples-1.yml b/tests/fixtures/docs-examples-1.yml new file mode 100644 index 0000000..abc35ed --- /dev/null +++ b/tests/fixtures/docs-examples-1.yml @@ -0,0 +1,30 @@ +version: 4 +name: lobby +description: Send the player to the lobby +enabled: true +aliases: [hub] + +permissions: + enabled: true + silent: false + +register: + - id: "survival-1" + location: BACKEND + +defaults: + run-as: PLAYER + execute: + - id: "proxy-1" + location: VELOCITY + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: [] + +commands: + - command: "server lobby" diff --git a/tests/fixtures/docs-examples-2.yml b/tests/fixtures/docs-examples-2.yml new file mode 100644 index 0000000..395d70c --- /dev/null +++ b/tests/fixtures/docs-examples-2.yml @@ -0,0 +1,39 @@ +version: 4 +name: eco-give +description: Give money to a player on a backend +enabled: true +aliases: [eco] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: player + required: true + type: PLAYERS + suggestions: [] + + - name: amount + required: true + type: INTEGER + suggestions: [] + +commands: + - command: "eco give ${player} ${amount}" diff --git a/tests/fixtures/docs-examples-3.yml b/tests/fixtures/docs-examples-3.yml new file mode 100644 index 0000000..ff26505 --- /dev/null +++ b/tests/fixtures/docs-examples-3.yml @@ -0,0 +1,47 @@ +version: 4 +name: netban +description: Ban a player on all servers +enabled: true +aliases: [nban] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + - id: "lobby-1" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: target + required: true + type: PLAYERS + suggestions: [] + + - name: reason + required: false + type: GREEDY_STRING + suggestions: [] + +commands: + - command: "ban ${target} ${reason}" + + - command: "say ${target} has been banned: ${reason}" + execute: + - id: "admin-1" + location: BACKEND + run-as: CONSOLE diff --git a/tests/fixtures/docs-examples-4.yml b/tests/fixtures/docs-examples-4.yml new file mode 100644 index 0000000..b08ad74 --- /dev/null +++ b/tests/fixtures/docs-examples-4.yml @@ -0,0 +1,36 @@ +version: 4 +name: claim-reward +description: Claim your daily reward +enabled: true +aliases: [reward] + +permissions: + enabled: true + silent: false + +register: + - id: "proxy-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: true + player-arg: "player" + delay: 0s + cooldown: 24h + +args: + - name: player + required: true + type: OFFLINE_PLAYER + suggestions: [] + +commands: + - command: "say ${player} is claiming their daily reward..." + - command: "give ${player} diamond 5" + delay: 30s diff --git a/tests/fixtures/docs-syntax-0.yml b/tests/fixtures/docs-syntax-0.yml new file mode 100644 index 0000000..5825a8f --- /dev/null +++ b/tests/fixtures/docs-syntax-0.yml @@ -0,0 +1,43 @@ +version: 4 + +name: example +description: An example command script - edit or replace this with your own +enabled: false +aliases: [ex] + +permissions: + enabled: true + silent: false + +register: + - id: "client-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "survival-1" + location: BACKEND + server: + target-required: false + schedule-online: false + player-arg: "" + delay: 0s + cooldown: 0s + +args: + - name: player + required: true + type: STRING + suggestions: [] + + - name: message + required: true + type: TEXT + suggestions: [] + +commands: + - command: "msg ${player} ${message}" + server: + target-required: true + delay: 2s diff --git a/tests/fixtures/repo-concept.yml b/tests/fixtures/repo-concept.yml new file mode 100644 index 0000000..9f7a51f --- /dev/null +++ b/tests/fixtures/repo-concept.yml @@ -0,0 +1,104 @@ +version: 4 # The configuration version. Must be 4 for the current schema. + +name: eco-give # The canonical name of the script and the command. This will be the primary label on the proxy. +description: Simple demo script that proxies an /eco command to a backend server # A short description shown in /help. +enabled: true # When false, the script is ignored and nothing is registered. +aliases: [eco] # Aliases for the command; must be a list even if you only have one. + +permissions: + enabled: true # If true, the proxy will check commandbridge.command. before executing the command. + silent: false # When true, lack of permission fails silently instead of sending an error message. + +# The register section tells CB on which clients and in which context to register this command. +# Each entry consists of: +# id: the client identifier from your clients config +# location: VELOCITY or BACKEND, specifying whether to register the command on the proxy or on a backend. +# If you have multiple proxies or backends you can register on more than one. +register: + - id: "client-1" + location: VELOCITY # Register on the Velocity proxy. + +defaults: + # These defaults are inherited by every command in the `commands` list unless overridden locally. + # Fields annotated with @Merge in the commands block (run-as, execute, server, delay, cooldown) + # are inherited from defaults when the command does not specify them. + run-as: CONSOLE # Options are CONSOLE, PLAYER or OPERATOR. Determines the executor context on the backend. + # Where to execute the command by default. Each entry has an `id` and a `location`. Multiple targets are allowed. + execute: + - id: "client-1" + location: BACKEND # Forward the command to this backend server. + # Settings that control player-presence checks and scheduling behaviour. + server: + target-required: true # If true, abort if the target player is not online on the execution target. Defaults to false. + schedule-online: true # If true, queue the command until the target comes online on the execution target. Defaults to false. + player-arg: player # References an arg name (must be a player-resolvable type: STRING, PLAYERS, or OFFLINE_PLAYER) to identify the player for target-required and schedule-online checks. + delay: 0s # Delay before forwarding the command. Zero disables the delay. + cooldown: 0s # Cooldown per player between uses. Zero disables rate limiting. + +# Argument definitions. +# Each argument must have: +# name: the placeholder used in the command template (for example ${player}) +# type: one of the values from the ArgType enum (see below). Defaults to STRING. +# required: whether the user must supply this argument. Defaults to false. +# suggestions: optional list of tab-complete suggestions for this argument +# +# Valid types as of this version: +# +# Cross-platform (VELOCITY + BACKEND): +# STRING # A single token. Player-resolvable. +# INTEGER # Whole numbers. +# BOOLEAN # true or false. +# DOUBLE # Floating-point numbers. +# TEXT # The rest of the input line as a single string, preserving spaces and quotes. +# GREEDY_STRING # Consumes all remaining input as a raw string. +# OFFLINE_PLAYER # An offline player name. Player-resolvable. Suggests known players on both platforms. +# +# Backend only: +# RANGE # A range like 1..10 (uses DoubleRangeArgument, no min/max support yet). +# ENTITIES # Select one or more entities (entity selector syntax). +# ENTITY_TYPE # The type of an entity (e.g. zombie). +# WORLD # A Minecraft world name. +# LOCATION # A 3D location (x, y, z). +# LOCATION_2D # A 2D location (x, z). +# ANGLE # A rotation angle. +# ROTATION # A full rotation (yaw + pitch). +# ITEM_STACK # An item stack. +# ENCHANTMENT # An enchantment. +# POTION_EFFECT # A potion effect. +# SOUND # A sound identifier. +# BIOME # A biome identifier. +# +# Backend (with optional Velocity support via PacketEvents): +# PLAYERS # Select one or more players (entity selector syntax). Player-resolvable. +# TIME # A time value (ticks or duration). +# +# Proxy only: +# SERVER # A Velocity server name. +# +# Types marked "Player-resolvable" can be referenced by the server.player-arg setting (STRING, PLAYERS, OFFLINE_PLAYER). +# Types with "optional Velocity support" work on the proxy only when the PacketEvents integration is available. +# The PlatformProcessor ensures you do not use a backend-only type for a proxy-registered command. +args: + - name: player + required: true + type: PLAYERS # Use the PLAYERS selector to pick a player or group of players. + + - name: amount + required: true + type: RANGE # A numeric range argument. + +commands: + # The list of commands to run. The proxy will process them in order. + # Each command can override any of the defaults (run-as, execute, server, delay, cooldown). + # When a field is not specified, it is inherited from defaults via @Merge. + - command: "eco give ${player} ${amount}" + # Overrides for this command only: + run-as: CONSOLE + execute: + - id: "client-1" + location: BACKEND + server: + target-required: false # Override defaults: do not abort if the target player is offline. + schedule-online: false # Do not schedule; run immediately if possible. + # player-arg not set here, so inherited from defaults via merge. + delay: 2s # Wait two seconds before sending the command to the backend. diff --git a/tests/fixtures/repo-example.yml b/tests/fixtures/repo-example.yml new file mode 100644 index 0000000..922b125 --- /dev/null +++ b/tests/fixtures/repo-example.yml @@ -0,0 +1,42 @@ +# CommandBridge - Example Script +# For a full reference of all options, visit https://cb.objz.dev/docs + +version: 4 + +name: example +description: An example command script - edit or replace this with your own +enabled: false # Set to true to activate this script +aliases: [ex] + +permissions: + enabled: true # Requires commandbridge.command.example to use + silent: false + +register: + - id: "client-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "client-1" + location: BACKEND + server: + target-required: false + schedule-online: false + delay: 0s + cooldown: 0s + +args: + - name: player + required: true + type: PLAYERS + suggestions: [] + + - name: message + required: true + type: TEXT + suggestions: [] + +commands: + - command: "msg ${player} ${message}" diff --git a/tests/fixtures/repo-testcmd.yml b/tests/fixtures/repo-testcmd.yml new file mode 100644 index 0000000..309b35a --- /dev/null +++ b/tests/fixtures/repo-testcmd.yml @@ -0,0 +1,44 @@ +version: 2 + +name: testcmd +description: Simple demo script +enabled: true +aliases: [tc] + +permissions: + enabled: true + silent: false + +register: + - id: "survival-1" + location: BACKEND + +defaults: + run-as: CONSOLE + execute: + - id: "client-1" + location: BACKEND + server: + target-required: true + schedule-online: true + timeout: 1s + delay: 0s + cooldown: 0s + + +args: + - name: player + required: true + type: PLAYERS + + - name: amount + required: true + type: RANGE + +commands: + - command: "eco give ${player} ${amount}" + run-as: CONSOLE + server: + target-required: false + schedule-online: false + delay: 2s diff --git a/tests/relay.test.mjs b/tests/relay.test.mjs new file mode 100644 index 0000000..09e8431 --- /dev/null +++ b/tests/relay.test.mjs @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import { webcrypto } from "node:crypto"; + +const source = readFileSync(new URL("../src/lib/editor/relay.js", import.meta.url), "utf8"); +const enc = (text) => new TextEncoder().encode(text); +const b64 = (data) => Buffer.from(data).toString("base64url"); +const unb64 = (text) => Buffer.from(text, "base64url"); + +async function page(secret = webcrypto.getRandomValues(new Uint8Array(32))) { + let socket, sent; + const errors = [], messages = []; + const window = { addEventListener() {} }; + class Socket { + constructor() { socket = this; } + send(frame) { sent?.(JSON.parse(frame)); } + close(code) { this.code = code; } + } + vm.runInNewContext(source, { window, location: { hostname: "localhost" }, crypto: webcrypto, WebSocket: Socket, TextEncoder, TextDecoder, Uint8Array, btoa, atob, + console: { error: (e) => errors.push(e), warn() {} } }); + const relay = window.cbRelay("a".repeat(32) + ":" + b64(secret)); + relay.onMessage = (message) => messages.push(message); + relay.connect(); + const send = (msg) => new Promise((resolve) => { sent = resolve; relay.send(msg); }); + const hello = await send({ type: "hello" }); + const ikm = await webcrypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveKey"]); + const key = await webcrypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: enc("commandbridge-relay-v1"), info: enc("aes-256-gcm") }, + ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + const seal = async (seq, message, role = "proxy", conn = hello.conn) => { + const iv = webcrypto.getRandomValues(new Uint8Array(12)); + const box = await webcrypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc(`${role}\n${conn}\n${seq}`) }, key, enc(JSON.stringify(message))); + return JSON.stringify({ conn, seq, iv: b64(iv), box: b64(box) }); + }; + const receive = async (frame) => { socket.onmessage({ data: frame }); await send({ type: "barrier" }); }; + return { relay, socket, key, conn: hello.conn, errors, messages, send, receive, seal }; +} + +test("large editor saves encrypt without overflowing the argument stack", { timeout: 5000 }, async () => { + const p = await page(); + const message = { type: "stage", files: { "big.yml": "say hello\n".repeat(30000) } }; + const frame = await p.send(message); + assert.deepEqual(p.errors, []); + const plain = await webcrypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(frame.iv), additionalData: enc(`editor\n${frame.conn}\n${frame.seq}`) }, p.key, unb64(frame.box)); + assert.deepEqual(JSON.parse(new TextDecoder().decode(plain)), message); +}); + +test("only authenticated, fresh messages for this page and role are delivered", { timeout: 5000 }, async () => { + const p = await page(); + const first = await p.seal(1, { type: "state" }); + await p.receive(first); + await p.receive(first); + await p.receive(await p.seal(2, { type: "reflection" }, "editor")); + await p.receive(await p.seal(2, { type: "other-tab" }, "proxy", "b".repeat(32))); + const corrupt = JSON.parse(await p.seal(100, { type: "tampered" })); + corrupt.box = b64(Buffer.alloc(32)); + await p.receive(JSON.stringify(corrupt)); + await p.receive(await p.seal(2, { type: "fresh" })); + assert.equal(JSON.stringify(p.messages), JSON.stringify([{ type: "state" }, { type: "fresh" }])); + assert.deepEqual(p.errors, []); +}); + +test("reloading the same link changes the page identity and rejects earlier replies", { timeout: 5000 }, async () => { + const secret = webcrypto.getRandomValues(new Uint8Array(32)); + const old = await page(secret), fresh = await page(secret); + assert.notEqual(old.conn, fresh.conn); + await fresh.receive(await old.seal(10, { type: "old" })); + assert.deepEqual(fresh.messages, []); +}); + +test("oversized and binary relay data are closed before decryption", { timeout: 5000 }, async () => { + for (const data of ["a".repeat(4 * 1024 * 1024 + 1), new ArrayBuffer(1)]) { + const p = await page(); + p.socket.onmessage({ data }); + assert.equal(p.socket.code, 1009); + assert.deepEqual(p.messages, []); + } +}); diff --git a/tests/script-fields.test.mjs b/tests/script-fields.test.mjs new file mode 100644 index 0000000..457a669 --- /dev/null +++ b/tests/script-fields.test.mjs @@ -0,0 +1,185 @@ +// Every field a script can have, changed one at a time through the editor model. +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import * as YAML from "yaml"; +import { toModel, fromModel, ARG_TYPES } from "../src/lib/editor/script-yaml.js"; + +const dir = new URL("./fixtures/", import.meta.url); +const fixtures = readdirSync(dir).map((f) => [f, readFileSync(new URL(f, dir), "utf8")]); +const concept = readFileSync(new URL("repo-concept.yml", dir), "utf8"); + +// drops what the file can't distinguish: _src, undefined, "", empty lists and empty objects +function norm(v) { + if (Array.isArray(v)) { + const a = v.map(norm).filter((x) => x !== undefined); + return a.length ? a : undefined; + } + if (v && typeof v === "object") { + const o = {}; + for (const [k, x] of Object.entries(v)) { + if (k === "_src") continue; + const n = norm(x); + if (n !== undefined) o[k] = n; + } + return Object.keys(o).length ? o : undefined; + } + return v === "" || v === null || v === undefined ? undefined : String(v); +} +const comments = (t) => t.split("\n").map((l) => l.match(/#.*$/)?.[0]).filter(Boolean); + +for (const [name, text] of fixtures) { + test(`${name}: unchanged round trip is byte for byte`, () => { + assert.equal(fromModel(YAML, text, toModel(YAML, text)), text); + }); +} + +const t = (m) => ({ id: m, location: "BACKEND" }); +const mutations = { + "name": (m) => { m.name = "renamed-cmd"; }, + "description set": (m) => { m.description = "new description"; }, + "description cleared": (m) => { m.description = undefined; }, + "enabled off": (m) => { m.enabled = false; }, + "aliases changed": (m) => { m.aliases = ["a1", "a2"]; }, + "aliases cleared": (m) => { m.aliases = []; }, + "permissions.enabled off": (m) => { m.permissions.enabled = false; }, + "permissions.silent on": (m) => { m.permissions.silent = true; }, + "register target added": (m) => { m.register.push(t("hub-1")); }, + "register location changed": (m) => { m.register[0].location = "BACKEND"; }, + "defaults.run-as": (m) => { m.defaults["run-as"] = "OPERATOR"; }, + "defaults.execute added": (m) => { m.defaults.execute.push(t("survival-2")); }, + "defaults.execute cleared": (m) => { m.defaults.execute = []; }, + "defaults.server.target-required": (m) => { m.defaults.server["target-required"] = false; }, + "defaults.server.schedule-online": (m) => { m.defaults.server["schedule-online"] = false; }, + "defaults.server.player-arg": (m) => { m.defaults.server["player-arg"] = "target"; }, + "defaults.delay": (m) => { m.defaults.delay = "5s"; }, + "defaults.cooldown": (m) => { m.defaults.cooldown = "1m"; }, + "defaults.delay zero": (m) => { m.defaults.delay = "0s"; }, + "arg name": (m) => { m.args[0].name = "who"; }, + "arg required": (m) => { m.args[0].required = !m.args[0].required; }, + "arg type": (m) => { m.args[0].type = "OFFLINE_PLAYER"; }, + "arg suggestions": (m) => { m.args[0].suggestions = ["steve", "alex"]; }, + "arg added": (m) => { m.args.push({ name: "extra", required: false, type: "INTEGER", suggestions: ["1"] }); }, + "arg removed": (m) => { m.args.pop(); }, + "args reordered": (m) => { m.args.reverse(); }, + "command text": (m) => { m.commands[0].command = "eco take ${player} ${amount}"; }, + "command run-as": (m) => { m.commands[0]["run-as"] = "PLAYER"; }, + "command execute override": (m) => { m.commands[0].execute = [t("lobby"), { id: "proxy-2", location: "VELOCITY" }]; }, + "command delay": (m) => { m.commands[0].delay = "500ms"; }, + "command cooldown": (m) => { m.commands[0].cooldown = "10s"; }, + "command server override": (m) => { m.commands[0].server = { "target-required": true, "schedule-online": false, "player-arg": "player" }; }, + "command server override cleared": (m) => { m.commands[0].server = undefined; }, + "command added": (m) => { m.commands.push({ command: "say added", delay: "1s", server: { "target-required": false, "schedule-online": true } }); }, + "command removed": (m) => { m.commands.pop(); }, + "commands reordered": (m) => { m.commands.reverse(); }, +}; + +for (const [label, mutate] of Object.entries(mutations)) { + test(`field: ${label}`, () => { + const m = toModel(YAML, concept); + m.defaults.execute ??= []; + m.defaults.server ??= {}; + if (m.commands.length < 2) m.commands.push({ command: "say second" }); + mutate(m); + const out = fromModel(YAML, concept, m); + assert.deepEqual(norm(toModel(YAML, out)), norm(m), "model after save differs"); + if (!/removed|cleared/.test(label)) { + const kept = comments(out); + for (const c of comments(concept)) assert.ok(kept.includes(c), "lost comment: " + c); + } + }); +} + +test("a script without permissions keeps checks on and writes nothing extra", () => { + const src = "version: 4\nname: bare\nregister: []\ndefaults: {}\nargs: []\ncommands:\n - command: say hi\n"; + const m = toModel(YAML, src); + assert.equal(m.permissions.enabled, true); + assert.equal(fromModel(YAML, src, m).includes("enabled: false"), false); +}); + +test("every argument type survives a round trip", () => { + const m = toModel(YAML, concept); + m.args = ARG_TYPES.map((type, i) => ({ name: "a" + i, required: false, type, suggestions: [] })); + assert.deepEqual(toModel(YAML, fromModel(YAML, concept, m)).args.map((a) => a.type), ARG_TYPES); +}); + +for (const [name, text] of fixtures) { + test(`${name}: editing one command changes exactly one line`, () => { + const m = toModel(YAML, text); + m.commands[0].command = "say edited"; + const a = text.split("\n"), b = fromModel(YAML, text, m).split("\n"); + assert.equal(b.length, a.length); + const changed = a.filter((l, i) => l !== b[i]); + assert.equal(changed.length, 1, "changed lines: " + JSON.stringify(changed)); + }); +} + +test("command server overrides keep their type", () => { + const m = toModel(YAML, concept); + m.commands[0].server = { "target-required": false, "schedule-online": true, "player-arg": "player" }; + const back = toModel(YAML, fromModel(YAML, concept, m)).commands[0].server; + assert.deepEqual(back, { "target-required": false, "schedule-online": true, "player-arg": "player" }); +}); + +test("a new argument spells out every field", () => { + const m = toModel(YAML, concept); + m.args.push({ name: "extra", required: false, type: "STRING", suggestions: [] }); + const added = YAML.parse(fromModel(YAML, concept, m)).args.at(-1); + assert.deepEqual(added, { name: "extra", required: false, type: "STRING", suggestions: [] }); +}); + +test("keys the file didn't have are inserted in the documented order", () => { + const src = "version: 4\nname: bare\nenabled: true\nregister: []\ndefaults:\n run-as: CONSOLE\nargs: []\ncommands:\n - command: say hi\n"; + const m = toModel(YAML, src); + m.description = "added"; + m.aliases = ["b"]; + m.defaults.cooldown = "1s"; + m.commands[0].delay = "2s"; + m.commands[0]["run-as"] = "PLAYER"; + const out = YAML.parse(fromModel(YAML, src, m)); + assert.deepEqual(Object.keys(out), ["version", "name", "description", "enabled", "aliases", "permissions", "register", "defaults", "args", "commands"]); + assert.deepEqual(Object.keys(out.commands[0]), ["command", "run-as", "delay"]); + assert.deepEqual(out.permissions, { enabled: true, silent: false }, "a missing required block is written out in full"); +}); + +test("items added to an empty inline list are written as a block list", () => { + const src = "version: 4\nname: bare\npermissions:\n enabled: false\nregister: []\ndefaults: {}\nargs: []\ncommands:\n - command: say hi\n"; + const m = toModel(YAML, src); + m.args.push({ name: "who", required: true, type: "STRING", suggestions: [] }); + m.register.push({ id: "proxy-1", location: "VELOCITY" }); + const out = fromModel(YAML, src, m); + assert.match(out, /\nargs:\n - name: who\n/); + assert.match(out, /\nregister:\n - id: proxy-1\n/); +}); + +test("targets without a source index keep their comments by matching id and location", () => { + const m = toModel(YAML, concept); + m.defaults.execute = [{ id: "client-1", location: "BACKEND" }, { id: "added", location: "BACKEND" }]; + const out = fromModel(YAML, concept, m); + assert.ok(out.includes("# Forward the command to this backend server.")); + assert.deepEqual(toModel(YAML, out).defaults.execute.map((t) => t.id), ["client-1", "added"]); +}); + +test("a zero default duration counts as no default", () => { + const m = toModel(YAML, concept); + m.defaults.delay = undefined; + m.defaults.cooldown = ""; + const out = fromModel(YAML, concept, m); + assert.ok(out.includes("delay: 0s") && out.includes("cooldown: 0s")); + m.defaults.delay = "3s"; + assert.equal(toModel(YAML, fromModel(YAML, concept, m)).defaults.delay, "3s"); +}); + +test("a step's server block is written whole, because it replaces the defaults' block", () => { + const m = toModel(YAML, concept); + m.commands[0].server = { "schedule-online": true }; + const back = YAML.parse(fromModel(YAML, concept, m)).commands[0].server; + assert.deepEqual(back, { "target-required": false, "schedule-online": true }); +}); + +test("a partial step server block is kept as is while its effective values don't change", () => { + const src = "version: 4\nname: part\npermissions:\n enabled: true\nregister: []\ndefaults: {}\nargs: []\ncommands:\n - command: say hi\n server:\n schedule-online: false\n"; + const m = toModel(YAML, src); + m.commands[0].server = { "target-required": false, "schedule-online": false }; + assert.equal(fromModel(YAML, src, m), src); +}); diff --git a/tests/script-yaml.test.mjs b/tests/script-yaml.test.mjs new file mode 100644 index 0000000..04bac20 --- /dev/null +++ b/tests/script-yaml.test.mjs @@ -0,0 +1,99 @@ +// node --test tests/ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import * as YAML from "yaml"; +import { toModel, fromModel } from "../src/lib/editor/script-yaml.js"; + +const EXAMPLE = `# header comment +version: 4 + +name: example +description: An example +enabled: false # Set to true to activate +aliases: [ex] + +permissions: + enabled: true # needs a node + silent: false + +register: + - id: "client-1" + location: VELOCITY + +defaults: + run-as: CONSOLE + execute: + - id: "client-1" + location: BACKEND + delay: 0s + +args: + - name: player + required: true + type: TEXT + +# what runs +commands: + # first step + - command: "msg \${player} hi" + # second step + - command: "say done" + delay: 2s +`; + +const strip = (m) => JSON.parse(JSON.stringify(m, (k, v) => (k === "_src" ? undefined : v))); + +test("unchanged model writes the file back byte for byte", () => { + assert.equal(fromModel(YAML, EXAMPLE, toModel(YAML, EXAMPLE)), EXAMPLE); +}); + +test("editing one command keeps every comment and other line", () => { + const m = toModel(YAML, EXAMPLE); + m.commands[0].command = "msg ${player} hello"; + m.enabled = true; + const out = fromModel(YAML, EXAMPLE, m); + for (const c of ["# header comment", "# Set to true to activate", "# needs a node", "# what runs", "# first step", "# second step"]) { + assert.ok(out.includes(c), "lost comment: " + c); + } + assert.ok(out.includes('msg ${player} hello')); + assert.ok(out.includes("enabled: true # Set to true to activate")); + assert.equal(out.split("\n").length, EXAMPLE.split("\n").length); +}); + +test("reorder, remove and add items; comments travel with their item", () => { + const m = toModel(YAML, EXAMPLE); + m.commands.reverse(); + m.commands[0].delay = undefined; + m.args.push({ name: "reason", required: false, type: "GREEDY_STRING", suggestions: [] }); + const out = fromModel(YAML, EXAMPLE, m); + const back = toModel(YAML, out); + assert.deepEqual(back.commands.map((c) => c.command), ["say done", "msg ${player} hi"]); + assert.equal(back.commands[0].delay, undefined); + assert.deepEqual(back.args.map((a) => a.name), ["player", "reason"]); + // a comment on a later item moves with it (the first item's comment belongs to the list) + assert.ok(out.indexOf("# second step") < out.indexOf("say done")); + assert.ok(out.indexOf("say done") < out.indexOf("msg ${player} hi")); +}); + +test("new script from a model parses back to the same model", () => { + const m = { + version: 4, name: "fresh", enabled: true, aliases: [], permissions: { enabled: false, silent: false }, + register: [{ id: "proxy-1", location: "VELOCITY" }], + defaults: { "run-as": "CONSOLE", execute: [{ id: "lobby", location: "BACKEND" }] }, + args: [{ name: "msg", required: true, type: "TEXT", suggestions: [] }], + commands: [{ command: "say ${msg}" }], + }; + const out = fromModel(YAML, null, m); + assert.ok(!out.includes("server:"), "empty server block written"); + const back = strip(toModel(YAML, out)); + assert.deepEqual(back.commands, m.commands); + assert.deepEqual(back.register, m.register); + assert.deepEqual(back.defaults.execute, m.defaults.execute); + assert.equal(back.name, "fresh"); +}); + +test("the shipped example script round-trips unchanged", () => { + const text = readFileSync(new URL("../../CommandBridge/velocity/src/main/resources/example.yml", import.meta.url), "utf8"); + assert.equal(fromModel(YAML, text, toModel(YAML, text)), text); +}); diff --git a/worker/README.md b/worker/README.md new file mode 100644 index 0000000..47feeec --- /dev/null +++ b/worker/README.md @@ -0,0 +1,51 @@ +# Cloudflare Dump Worker + +This worker stores `/cb dump` payloads in Cloudflare D1 and serves them back to a dedicated viewer page. + +## Endpoints + +- `POST /api/dump/upload` + - Requires `Authorization: Bearer cb-dump-upload-v1` + - Body: JSON dump payload + - Response: `{ id, url, createdAt, expiresAt }` where `url` points to `/dump/?id=` +- `GET /api/dump/:id` + - Response: dump envelope +- `GET /api/dump/view/:id` + - Compatibility redirect to `/dump/?id=` + +- `GET /api/editor/:id/ws?role=proxy|editor` (WebSocket) + - Live editor relay, one Durable Object per session + - `proxy` requires the same bearer token; `editor` can only join while the proxy is connected + - One connection per role, forwarded as-is; sessions close after 1 hour or when the proxy leaves + - Messages use end-to-end AES-256-GCM encryption; the role, page connection ID and sequence number are authenticated. The relay never sees the link secret. + - Text frames are limited to 4 MiB; binary and oversized frames close the sender before forwarding. + +- `GET /api/pair/:id/ws?role=proxy|server` (WebSocket) + - One proxy and one server per code hash; text frames are limited to 64 KiB. + - Pairing channels expire after 30 minutes or when either side leaves. + +The bundled bearer token is public client identification, not proof that a proxy is yours. Only use links and pairing codes obtained from your own proxy console, and verify the destination before confirming pairing. + +## Local checks + +From `worker/`, run `node --test test/worker-security.test.mjs` with Node 22.13+ for isolated upload bounds, relay size and closing-socket checks. Against `npx wrangler dev --port 8787`, run `node test/editor-relay.mjs` and `node test/pair-relay.mjs` for live local WebSocket checks. + +Run `node test/sustained-load.mjs --self-test` without network traffic, then `node test/sustained-load.mjs 300` against the local development server. It measures 100 encrypted round trips per second across 10 editor sessions with 1 KiB payloads for five minutes. The summary reports loss, duplicates, errors and round-trip latency. Non-loopback destinations are refused. This checks local relay forwarding, not production Cloudflare capacity or dump/database throughput. + +Deploy this service separately with `npx wrangler deploy` from `worker/`. Moving it from the plugin repo does not change its worker name, Durable Object migrations, or D1 binding. + +## Database + +Apply schema to D1: + +```bash +wrangler d1 execute commandbridge-dumps --remote --file schema.sql +``` + +## Deploy + +Run from this directory: + +```bash +wrangler deploy +``` diff --git a/worker/schema.sql b/worker/schema.sql new file mode 100644 index 0000000..eeb443a --- /dev/null +++ b/worker/schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS dumps ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + payload TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_dumps_expires_at ON dumps(expires_at); diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..c2b3f56 --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,421 @@ +interface Env { + DUMP_DB: D1Database; + DUMP_AUTH_TOKEN?: string; + EDITOR_RELAY: DurableObjectNamespace; + PAIR_RELAY: DurableObjectNamespace; +} + +const EDITOR_SESSION_TTL_MS = 60 * 60 * 1000; +const PAIR_CODE_TTL_MS = 30 * 60 * 1000; +const MAX_EDITOR_FRAME_BYTES = 4 * 1024 * 1024; +const MAX_PAIR_FRAME_BYTES = 64 * 1024; + +const DEFAULT_RETENTION_DAYS = 14; +const MAX_RETENTION_DAYS = 30; +const MAX_DUMP_BYTES = 2_000_000; + +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Dump-Retention-Days", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", +}; + +const JSON_HEADERS: Record = { + ...CORS_HEADERS, + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", +}; + +type DumpRow = { + id: string; + created_at: number; + expires_at: number; + payload: string; +}; + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + const url = new URL(request.url); + + if (url.pathname === "/api/dump/upload") { + return handleUpload(request, env, ctx); + } + + if (url.pathname.startsWith("/api/dump/view/")) { + const id = url.pathname.substring("/api/dump/view/".length).trim(); + return redirectToPublicDump(id); + } + + if (url.pathname === "/api/dump/view") { + const id = (url.searchParams.get("id") || "").trim(); + return redirectToPublicDump(id); + } + + if (url.pathname.startsWith("/api/dump/")) { + const id = url.pathname.substring("/api/dump/".length).trim(); + return handleFetch(id, env); + } + + const editorMatch = url.pathname.match(/^\/api\/editor\/([0-9a-f]{32})\/ws$/); + if (editorMatch) { + return handleEditorSocket(request, env, editorMatch[1], url.searchParams.get("role")); + } + + const pairMatch = url.pathname.match(/^\/api\/pair\/([0-9a-f]{64})\/ws$/); + if (pairMatch) { + return handlePairSocket(request, env, pairMatch[1], url.searchParams.get("role")); + } + + if (url.pathname === "/api/health") { + return json(200, { ok: true, service: "cb-dump-worker", storage: "d1" }); + } + + return json(404, { error: "Not found" }); + }, +}; + +async function handleUpload(request: Request, env: Env, ctx: ExecutionContext): Promise { + if (request.method !== "POST") { + return json(405, { error: "Method not allowed" }); + } + + const expectedToken = env.DUMP_AUTH_TOKEN || "cb-dump-upload-v1"; + const authorization = request.headers.get("Authorization") || ""; + if (authorization !== `Bearer ${expectedToken}`) { + return json(401, { error: "Unauthorized" }); + } + + let payload: unknown; + try { + const reader = request.body?.getReader(); + if (!reader) return json(400, { error: "Invalid JSON body" }); + const decoder = new TextDecoder(); + let body = "", bytes = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_DUMP_BYTES) { + await reader.cancel(); + return json(413, { error: "Dump payload too large" }); + } + body += decoder.decode(value, { stream: true }); + } + payload = JSON.parse(body + decoder.decode()); + } catch { + return json(400, { error: "Invalid JSON body" }); + } + + if (!isObject(payload)) { + return json(400, { error: "Dump payload must be a JSON object" }); + } + + const payloadText = JSON.stringify(payload); + const payloadBytes = new TextEncoder().encode(payloadText).byteLength; + if (payloadBytes > MAX_DUMP_BYTES) { + return json(413, { error: "Dump payload too large" }); + } + + const retentionDays = parseRetentionDays(request.headers.get("X-Dump-Retention-Days")); + const nowSec = Math.floor(Date.now() / 1000); + const expiresAtSec = nowSec + retentionDays * 24 * 60 * 60; + const id = randomId(); + + await env.DUMP_DB.prepare( + "INSERT INTO dumps (id, created_at, expires_at, payload) VALUES (?1, ?2, ?3, ?4)" + ) + .bind(id, nowSec, expiresAtSec, payloadText) + .run(); + + ctx.waitUntil( + env.DUMP_DB.prepare("DELETE FROM dumps WHERE expires_at < ?1") + .bind(nowSec) + .run() + .then(() => undefined) + .catch(() => undefined) + ); + + return json(201, { + id, + url: publicDumpUrl(id), + createdAt: new Date(nowSec * 1000).toISOString(), + expiresAt: new Date(expiresAtSec * 1000).toISOString(), + }); +} + +async function handleFetch(id: string, env: Env): Promise { + if (!isValidDumpId(id)) { + return json(400, { error: "Invalid dump id" }); + } + + const row = await env.DUMP_DB.prepare( + "SELECT id, created_at, expires_at, payload FROM dumps WHERE id = ?1" + ) + .bind(id) + .first(); + + if (!row) { + return json(404, { error: "Dump not found or expired" }); + } + + const nowSec = Math.floor(Date.now() / 1000); + if (row.expires_at < nowSec) { + await env.DUMP_DB.prepare("DELETE FROM dumps WHERE id = ?1").bind(id).run(); + return json(404, { error: "Dump not found or expired" }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(row.payload); + } catch { + return json(500, { error: "Stored dump payload is invalid" }); + } + + return json(200, { + id: row.id, + createdAt: new Date(row.created_at * 1000).toISOString(), + expiresAt: new Date(row.expires_at * 1000).toISOString(), + data: parsed, + }); +} + +async function handleEditorSocket( + request: Request, + env: Env, + id: string, + role: string | null +): Promise { + if (request.headers.get("Upgrade") !== "websocket") { + return json(426, { error: "Expected WebSocket upgrade" }); + } + if (role !== "proxy" && role !== "editor") { + return json(400, { error: "Invalid role" }); + } + if (role === "proxy") { + const expectedToken = env.DUMP_AUTH_TOKEN || "cb-dump-upload-v1"; + if (request.headers.get("Authorization") !== `Bearer ${expectedToken}`) { + return json(401, { error: "Unauthorized" }); + } + } + return env.EDITOR_RELAY.get(env.EDITOR_RELAY.idFromName(id)).fetch(request); +} + +/** + * Relays messages between one proxy and one editor. It never reads them: both + * ends encrypt every message with a secret the relay never sees. + */ +export class EditorRelay implements DurableObject { + private state: DurableObjectState; + constructor(state: DurableObjectState) { this.state = state; } + + async fetch(request: Request): Promise { + const role = new URL(request.url).searchParams.get("role") as "proxy" | "editor"; + + // the relay accepts a page and then closes it with a reason, so the page can show why; + // browsers also throttle new connections after refused ones + if (role === "editor" && this.state.getWebSockets("proxy").length === 0) { + return closedWith(4404, "No proxy is waiting on this link anymore. Run the command on your proxy again for a new one."); + } + if (role === "proxy" && this.state.getWebSockets("proxy").length > 0) { + return json(409, { error: "A proxy is already connected to this session" }); + } + // a reload or a second tab takes over; the old page is told where it went + for (const old of this.state.getWebSockets("editor")) { + safeClose(old, 4001, "Opened in another tab"); + } + const pair = new WebSocketPair(); + this.state.acceptWebSocket(pair[1], [role]); + if (role === "proxy") { + await this.state.storage.setAlarm(Date.now() + EDITOR_SESSION_TTL_MS); + } + return new Response(null, { status: 101, webSocket: pair[0] }); + } + + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + if (!acceptRelayFrame(ws, message, MAX_EDITOR_FRAME_BYTES)) return; + const other = this.roleOf(ws) === "proxy" ? "editor" : "proxy"; + for (const peer of this.state.getWebSockets(other)) { + if (peer.readyState === WebSocket.OPEN) peer.send(message); + } + } + + async webSocketClose(ws: WebSocket, code: number, reason: string): Promise { + if (this.roleOf(ws) === "proxy") { + this.closeAll(1000, "Proxy closed the session"); + } else { + safeClose(ws, code, reason); + } + } + + async webSocketError(ws: WebSocket): Promise { + await this.webSocketClose(ws, 1011, "Socket error"); + } + + async alarm(): Promise { + this.closeAll(1000, "Editor session expired"); + } + + private roleOf(ws: WebSocket): string { + return this.state.getTags(ws)[0]; + } + + private closeAll(code: number, reason: string): void { + for (const ws of this.state.getWebSockets()) { + safeClose(ws, code, reason); + } + } +} + +async function handlePairSocket( + request: Request, + env: Env, + id: string, + role: string | null +): Promise { + if (request.headers.get("Upgrade") !== "websocket") { + return json(426, { error: "Expected WebSocket upgrade" }); + } + if (role !== "proxy" && role !== "server") { + return json(400, { error: "Invalid role" }); + } + if (role === "proxy") { + const expectedToken = env.DUMP_AUTH_TOKEN || "cb-dump-upload-v1"; + if (request.headers.get("Authorization") !== `Bearer ${expectedToken}`) { + return json(401, { error: "Unauthorized" }); + } + } + return env.PAIR_RELAY.get(env.PAIR_RELAY.idFromName(id)).fetch(request); +} + +/** + * Joins the proxy holding a pairing code with the server that runs /cbc pair. + * The channel is named by a hash of the code and the config is sealed with a + * key derived from it, so the relay only ever forwards ciphertext. + */ +export class PairRelay implements DurableObject { + private state: DurableObjectState; + constructor(state: DurableObjectState) { this.state = state; } + + async fetch(request: Request): Promise { + const role = new URL(request.url).searchParams.get("role") as "proxy" | "server"; + + if (this.state.getWebSockets(role).length > 0) { + return json(409, { error: `A ${role} is already on this code` }); + } + if (role === "server" && this.state.getWebSockets("proxy").length === 0) { + return json(404, { error: "No setup is waiting for this code" }); + } + const pair = new WebSocketPair(); + this.state.acceptWebSocket(pair[1], [role]); + if (role === "proxy") { + await this.state.storage.setAlarm(Date.now() + PAIR_CODE_TTL_MS); + } + return new Response(null, { status: 101, webSocket: pair[0] }); + } + + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + if (!acceptRelayFrame(ws, message, MAX_PAIR_FRAME_BYTES)) return; + const other = this.state.getTags(ws)[0] === "proxy" ? "server" : "proxy"; + for (const peer of this.state.getWebSockets(other)) { + if (peer.readyState === WebSocket.OPEN) peer.send(message); + } + } + + async webSocketClose(ws: WebSocket, code: number, reason: string): Promise { + this.closeAll(1000, "The other side left"); + } + + async webSocketError(ws: WebSocket): Promise { + this.closeAll(1011, "Socket error"); + } + + async alarm(): Promise { + this.closeAll(1000, "Pairing code expired"); + } + + private closeAll(code: number, reason: string): void { + for (const ws of this.state.getWebSockets()) { + safeClose(ws, code, reason); + } + } +} + +function acceptRelayFrame(ws: WebSocket, message: string | ArrayBuffer, limit: number): message is string { + if (ws.readyState !== WebSocket.OPEN) return false; + if (typeof message !== "string") { + safeClose(ws, 1003, "Expected a text message"); + return false; + } + if (message.length > limit || new TextEncoder().encode(message).byteLength > limit) { + safeClose(ws, 1009, "Message too large"); + return false; + } + return true; +} + +function closedWith(code: number, reason: string): Response { + const pair = new WebSocketPair(); + pair[1].accept(); + pair[1].close(code, reason); + return new Response(null, { status: 101, webSocket: pair[0] }); +} + +function safeClose(ws: WebSocket, code: number, reason: string): void { + try { + ws.close(code, reason); + } catch { + // already closed + } +} + +function redirectToPublicDump(id: string): Response { + if (!isValidDumpId(id)) { + return json(400, { error: "Invalid dump id" }); + } + + return new Response(null, { + status: 302, + headers: { + ...CORS_HEADERS, + Location: publicDumpUrl(id), + }, + }); +} + +function publicDumpUrl(id: string): string { + return `https://cb.objz.dev/dump/?id=${encodeURIComponent(id)}`; +} + +function parseRetentionDays(raw: string | null): number { + if (!raw) { + return DEFAULT_RETENTION_DAYS; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_RETENTION_DAYS; + } + return Math.min(parsed, MAX_RETENTION_DAYS); +} + +function randomId(): string { + return crypto.randomUUID().replace(/-/g, "").slice(0, 16); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isValidDumpId(id: string): boolean { + return /^[a-zA-Z0-9_-]{8,64}$/.test(id); +} + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: JSON_HEADERS, + }); +} diff --git a/worker/test/editor-relay.mjs b/worker/test/editor-relay.mjs new file mode 100644 index 0000000..4088f71 --- /dev/null +++ b/worker/test/editor-relay.mjs @@ -0,0 +1,52 @@ +// Checks how the /api/editor relay treats pages, against `npx wrangler dev --port 8787`. +// Run from the wiki repo: node worker/test/editor-relay.mjs [base] +import { randomBytes } from "node:crypto"; + +const base = (process.argv[2] || "http://localhost:8787").replace(/^http/, "ws"); +const auth = { Authorization: "Bearer cb-dump-upload-v1" }; +let failed = 0; +const session = () => randomBytes(16).toString("hex"); +const url = (id, role) => `${base}/api/editor/${id}/ws?role=${role}`; +const open = (id, role) => { + const ws = new WebSocket(url(id, role), role === "proxy" ? { headers: auth } : undefined); + return new Promise((ok, no) => { ws.onopen = () => ok(ws); ws.onerror = () => no(new Error(role + " failed")); }); +}; +const closed = (ws) => new Promise((r) => ws.addEventListener("close", (e) => r(e), { once: true })); +const next = (ws) => new Promise((r) => ws.addEventListener("message", (e) => r(String(e.data)), { once: true })); +const expect = (c, m) => { if (!c) throw new Error(m); }; +async function check(name, fn) { + try { await fn(); console.log("PASS " + name); } catch (e) { failed++; console.log("FAIL " + name + ": " + e.message); } +} + +await check("a page on an ended session is told why", async () => { + const page = new WebSocket(url(session(), "editor")); + const e = await closed(page); + expect(e.code === 4404, "code " + e.code); + expect(/run the command on your proxy again/i.test(e.reason), "reason " + e.reason); +}); + +await check("a second tab takes over from the first", async () => { + const id = session(); + const proxy = await open(id, "proxy"); + const first = await open(id, "editor"); + const firstClosed = closed(first); + const second = await open(id, "editor"); + const e = await firstClosed; + expect(e.code === 4001, "first closed with " + e.code); + const got = next(second); + proxy.send("hi"); + expect((await got) === "hi", "second tab got nothing"); + proxy.close(); +}); + +await check("an oversized editor frame closes its sender", async () => { + const id = session(); + const proxy = await open(id, "proxy"); + const page = await open(id, "editor"); + const ended = closed(page); + page.send("a".repeat(4 * 1024 * 1024 + 1)); + expect((await ended).code === 1009, "sender was not closed for message size"); + proxy.close(); +}); + +process.exit(failed ? 1 : 0); diff --git a/worker/test/pair-relay.mjs b/worker/test/pair-relay.mjs new file mode 100644 index 0000000..f970b27 --- /dev/null +++ b/worker/test/pair-relay.mjs @@ -0,0 +1,109 @@ +// Checks the /api/pair relay channel against `npx wrangler dev --port 8787`. +// Run from the wiki repo: node worker/test/pair-relay.mjs [base] +import { randomBytes } from "node:crypto"; +import http from "node:http"; + +const base = process.argv[2] || "http://localhost:8787"; +const wsBase = base.replace(/^http/, "ws"); +const auth = { Authorization: "Bearer cb-dump-upload-v1" }; +let failed = 0; + +const channel = () => randomBytes(32).toString("hex"); +const path = (id, role) => `/api/pair/${id}/ws?role=${role}`; + +function open(id, role) { + const ws = new WebSocket(wsBase + path(id, role), role === "proxy" ? { headers: auth } : undefined); + return new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(ws), { once: true }); + ws.addEventListener("error", () => reject(new Error(`${role} socket failed`)), { once: true }); + }); +} + +function next(ws) { + return new Promise((resolve) => ws.addEventListener("message", (e) => resolve(String(e.data)), { once: true })); +} + +// fetch() refuses an Upgrade header, so the refusals are read with a raw request. +function upgrade(id, role, headers = {}) { + return new Promise((resolve, reject) => { + const req = http.get(base + path(id, role), { + headers: { Upgrade: "websocket", Connection: "Upgrade", "Sec-WebSocket-Version": "13", "Sec-WebSocket-Key": randomBytes(16).toString("base64"), ...headers }, + }); + req.on("upgrade", (res, socket) => { + socket.destroy(); + resolve({ status: 101, json: async () => ({}) }); + }); + req.on("response", (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => resolve({ status: res.statusCode, json: async () => JSON.parse(body) })); + }); + req.on("error", reject); + }); +} + +async function check(name, fn) { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (e) { + failed++; + console.log(`FAIL ${name}: ${e.message}`); + } +} + +function expect(cond, msg) { + if (!cond) throw new Error(msg); +} + +await check("server without a proxy is refused", async () => { + const id = channel(); + const res = await upgrade(id, "server"); + expect(res.status === 404, `status ${res.status}`); + const body = await res.json(); + expect(body.error === "No setup is waiting for this code", JSON.stringify(body)); + let opened = true; + await open(id, "server").catch(() => (opened = false)); + expect(!opened, "socket opened"); +}); + +await check("messages pass both ways", async () => { + const id = channel(); + const proxy = await open(id, "proxy"); + const server = await open(id, "server"); + const atProxy = next(proxy); + server.send("a"); + expect((await atProxy) === "a", "proxy did not get a"); + const atServer = next(server); + proxy.send("b"); + expect((await atServer) === "b", "server did not get b"); + proxy.close(); + server.close(); +}); + +await check("a second server is refused", async () => { + const id = channel(); + const proxy = await open(id, "proxy"); + const server = await open(id, "server"); + const res = await upgrade(id, "server"); + expect(res.status === 409, `status ${res.status}`); + proxy.close(); + server.close(); +}); + +await check("a proxy without the token is refused", async () => { + const res = await upgrade(channel(), "proxy", {}); + expect(res.status === 401, `status ${res.status}`); +}); + +await check("an oversized pairing frame closes its sender", async () => { + const id = channel(); + const proxy = await open(id, "proxy"); + const server = await open(id, "server"); + const ended = new Promise((resolve) => server.addEventListener("close", resolve, { once: true })); + server.send("a".repeat(64 * 1024 + 1)); + expect((await ended).code === 1009, "sender was not closed for message size"); + proxy.close(); +}); + +process.exit(failed ? 1 : 0); diff --git a/worker/test/sustained-load.mjs b/worker/test/sustained-load.mjs new file mode 100644 index 0000000..285f158 --- /dev/null +++ b/worker/test/sustained-load.mjs @@ -0,0 +1,127 @@ +// Loopback-only, paced relay check. Run: node worker/test/sustained-load.mjs [seconds=300] [ws-base] +import assert from "node:assert/strict"; +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { setTimeout as pause } from "node:timers/promises"; + +const sessions = 10, rate = 100; +function localBase(text) { + const url = new URL(text); + assert.equal(url.protocol, "ws:"); + assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(url.hostname), "Load tests only permit loopback"); + assert.ok(!url.username && !url.password && !url.search && !url.hash && url.pathname === "/"); + return url.origin; +} +function seal(key, conn, role, seq, body) { + const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", key, iv); + cipher.setAAD(Buffer.from(`${role}\n${conn}\n${seq}`)); + const box = Buffer.concat([cipher.update(JSON.stringify(body)), cipher.final(), cipher.getAuthTag()]); + return JSON.stringify({ conn, seq, iv: iv.toString("base64url"), box: box.toString("base64url") }); +} +function open(key, conn, role, frame) { + const envelope = JSON.parse(frame), box = Buffer.from(envelope.box, "base64url"); + assert.equal(envelope.conn, conn); + const cipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64url")); + cipher.setAAD(Buffer.from(`${role}\n${conn}\n${envelope.seq}`)); + cipher.setAuthTag(box.subarray(-16)); + const body = JSON.parse(Buffer.concat([cipher.update(box.subarray(0, -16)), cipher.final()]).toString()); + assert.equal(body.seq, envelope.seq); + return body; +} +function percentile(values, fraction) { + return values.length ? Math.round(values[Math.ceil(values.length * fraction) - 1] * 100) / 100 : null; +} + +if (process.argv[2] === "--self-test") { + const key = randomBytes(32), conn = randomBytes(16).toString("hex"); + const frame = seal(key, conn, "editor", 1, { seq: 1, text: "hello" }); + assert.deepEqual(open(key, conn, "editor", frame), { seq: 1, text: "hello" }); + assert.throws(() => open(key, conn, "proxy", frame)); + assert.throws(() => localBase("wss://cb.objz.dev/")); + assert.throws(() => localBase("ws://example.com/")); + assert.equal(percentile([1, 2, 3, 4], .95), 4); + console.log("Relay load self-checks passed; no network traffic sent"); +} else { + const seconds = Number(process.argv[2] || 300), base = localBase(process.argv[3] || "ws://127.0.0.1:8787/"); + assert.ok(Number.isInteger(seconds) && seconds > 0 && seconds <= 600); + const sockets = [], pairs = [], failures = [], latencies = []; + let sent = 0, delivered = 0, duplicates = 0, unexpected = 0, closedEarly = 0, stopping = false; + function connect(url, proxy) { + const ws = new WebSocket(url, proxy ? { headers: { Authorization: "Bearer cb-dump-upload-v1" } } : undefined); + sockets.push(ws); + ws.addEventListener("close", () => { if (!stopping) closedEarly++; }); + ws.addEventListener("error", () => { failures.push("WebSocket error"); }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { ws.close(); reject(new Error("Relay connection timed out")); }, 10000); + ws.onopen = () => { clearTimeout(timer); resolve(ws); }; + ws.onerror = () => { clearTimeout(timer); reject(new Error("Relay connection failed")); }; + }); + } + let result, failure; + try { + for (let i = 0; i < sessions; i++) { + const id = randomBytes(16).toString("hex"), conn = randomBytes(16).toString("hex"); + const key = Buffer.from(hkdfSync("sha256", randomBytes(32), "commandbridge-relay-v1", "aes-256-gcm", 32)); + const proxy = await connect(`${base}/api/editor/${id}/ws?role=proxy`, true); + const page = await connect(`${base}/api/editor/${id}/ws?role=editor`, false); + const pair = { proxy, page, conn, key, seq: 0, sent: new Map(), seen: new Set(), requests: new Set() }; + pairs.push(pair); + proxy.onmessage = (event) => { + try { + const body = open(key, conn, "editor", event.data); + assert.ok(pair.sent.has(body.seq) && !pair.requests.has(body.seq), "Unexpected or repeated request"); + assert.equal(body.pad, "x".repeat(1024)); + pair.requests.add(body.seq); + proxy.send(seal(key, conn, "proxy", body.seq, body)); + } catch { failures.push("Invalid request or forwarding failure"); } + }; + page.onmessage = (event) => { + try { + const body = open(key, conn, "proxy", event.data); + assert.equal(body.pad, "x".repeat(1024)); + if (!pair.sent.has(body.seq)) { unexpected++; return; } + if (pair.seen.has(body.seq)) { duplicates++; return; } + pair.seen.add(body.seq); + delivered++; + latencies.push(performance.now() - pair.sent.get(body.seq)); + } catch { failures.push("Invalid reply"); } + }; + } + const start = performance.now(); + for (let tick = 0; tick < seconds * rate / sessions; tick++) { + await pause(Math.max(0, start + tick * sessions * 1000 / rate - performance.now())); + assert.ok(!failures.length && !closedEarly, "Relay failed during sustained traffic"); + assert.ok(sent - delivered < rate * 10, "Relay backlog exceeds ten seconds"); + for (const pair of pairs) { + const seq = ++pair.seq; + pair.sent.set(seq, performance.now()); + pair.page.send(seal(pair.key, pair.conn, "editor", seq, { seq, pad: "x".repeat(1024) })); + sent++; + } + } + await pause(Math.max(0, start + seconds * 1000 - performance.now())); + const deadline = performance.now() + 10000; + while (delivered < sent && performance.now() < deadline) await pause(20); + await pause(500); + latencies.sort((a, b) => a - b); + result = { seconds, sessions, target_roundtrips_per_second: rate, payload_bytes: 1024, sent, delivered, + missing: sent - delivered, duplicates, unexpected, closed_early: closedEarly, errors: failures.length, + elapsed_seconds: Math.round((performance.now() - start) / 10) / 100, + latency_ms: { p50: percentile(latencies, .5), p95: percentile(latencies, .95), + p99: percentile(latencies, .99), max: latencies.length ? latencies.at(-1) : null } }; + assert.equal(sent, seconds * rate); + assert.equal(delivered, sent); + assert.ok(!duplicates && !unexpected && !closedEarly && !failures.length); + } catch (error) { + failure = error; + } finally { + stopping = true; + for (const ws of sockets) { + if (ws.readyState < WebSocket.CLOSING) ws.close(1000, "Load check finished"); + } + await pause(500); + } + console.log(JSON.stringify(result || { sent, delivered, errors: failures.length, failed: true }, null, 2)); + if (failure) console.error(failure.message); + process.exit(failure ? 1 : 0); +} diff --git a/worker/test/worker-security.test.mjs b/worker/test/worker-security.test.mjs new file mode 100644 index 0000000..9b5da70 --- /dev/null +++ b/worker/test/worker-security.test.mjs @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; + +const source = stripTypeScriptTypes(readFileSync(new URL("../src/index.ts", import.meta.url), "utf8")); +const { default: worker, EditorRelay, PairRelay } = await import("data:text/javascript;base64," + Buffer.from(source).toString("base64")); + +function socket(role) { + return { role, readyState: WebSocket.OPEN, sent: [], send(message) { this.sent.push(message); }, close(code) { this.code = code; this.readyState = WebSocket.CLOSING; } }; +} + +test("dump upload bounds raw request bytes before parsing or storing", async () => { + let cancelled = false, reads = 0; + const body = new ReadableStream({ + pull(controller) { + reads++; + controller.enqueue(new TextEncoder().encode(reads === 1 ? "{}" : " ".repeat(1_000_000))); + }, + cancel() { cancelled = true; }, + }); + const request = new Request("https://worker.test/api/dump/upload", { method: "POST", headers: { Authorization: "Bearer cb-dump-upload-v1" }, body, duplex: "half" }); + const response = await worker.fetch(request, {}, {}); + assert.equal(response.status, 413); + assert.equal(cancelled, true); + assert.ok(reads <= 4); +}); + +test("dump upload still rejects malformed or missing JSON", async () => { + for (const body of [undefined, "{broken"]) { + const request = new Request("https://worker.test/api/dump/upload", { method: "POST", headers: { Authorization: "Bearer cb-dump-upload-v1" }, body }); + assert.equal((await worker.fetch(request, {}, {})).status, 400); + } +}); + +test("valid dump JSON survives a split UTF-8 character and is stored", async () => { + const text = '{"note":"é"}', bytes = new TextEncoder().encode(text); + const split = bytes.indexOf(0xc3) + 1; + const body = new ReadableStream({ start(controller) { controller.enqueue(bytes.slice(0, split)); controller.enqueue(bytes.slice(split)); controller.close(); } }); + let stored; + const env = { DUMP_DB: { prepare(sql) { return { bind(...values) { if (sql.startsWith("INSERT")) stored = values[3]; return this; }, async run() {} }; } } }; + const request = new Request("https://worker.test/api/dump/upload", { method: "POST", headers: { Authorization: "Bearer cb-dump-upload-v1" }, body, duplex: "half" }); + const response = await worker.fetch(request, env, { waitUntil() {} }); + assert.equal(response.status, 201); + assert.equal(stored, text); +}); + +for (const [Relay, receiver, limit] of [[EditorRelay, "editor", 4 * 1024 * 1024], [PairRelay, "server", 64 * 1024]]) { + function channel() { + const proxy = socket("proxy"), peer = socket(receiver); + const state = { getTags: (ws) => [ws.role], getWebSockets: (role) => [proxy, peer].filter((ws) => !role || ws.role === role) }; + return { relay: new Relay(state), proxy, peer }; + } + + test(`${Relay.name}: frames up to the protocol limit pass both ways`, async () => { + const { relay, proxy, peer } = channel(); + const frame = "a".repeat(limit); + await relay.webSocketMessage(proxy, frame); + await relay.webSocketMessage(peer, "reply"); + assert.deepEqual(peer.sent, [frame]); + assert.deepEqual(proxy.sent, ["reply"]); + }); + + test(`${Relay.name}: oversized UTF-8 frames are closed before forwarding`, async () => { + for (const frame of ["a".repeat(limit + 1), "é".repeat(limit / 2 + 1)]) { + const { relay, proxy, peer } = channel(); + await relay.webSocketMessage(peer, frame); + assert.equal(peer.code, 1009); + assert.deepEqual(proxy.sent, []); + } + }); + + test(`${Relay.name}: binary frames are not forwarded`, async () => { + const { relay, proxy, peer } = channel(); + await relay.webSocketMessage(proxy, new ArrayBuffer(1)); + assert.equal(proxy.code, 1003); + assert.deepEqual(peer.sent, []); + }); + + test(`${Relay.name}: replaced or closing sockets cannot send or receive`, async () => { + const { relay, proxy, peer } = channel(); + peer.close(4001); + await relay.webSocketMessage(peer, "stale edit"); + await relay.webSocketMessage(proxy, "private reply"); + assert.deepEqual(proxy.sent, []); + assert.deepEqual(peer.sent, []); + }); +} diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..7fd6767 --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,27 @@ +name = "dawn-field-90e4" +main = "src/index.ts" +compatibility_date = "2026-03-10" + +[vars] +DUMP_AUTH_TOKEN = "cb-dump-upload-v1" + +[[durable_objects.bindings]] +name = "EDITOR_RELAY" +class_name = "EditorRelay" + +[[durable_objects.bindings]] +name = "PAIR_RELAY" +class_name = "PairRelay" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["EditorRelay"] + +[[migrations]] +tag = "v2" +new_sqlite_classes = ["PairRelay"] + +[[d1_databases]] +binding = "DUMP_DB" +database_name = "commandbridge-dumps" +database_id = "ee1f540f-16ea-4f4b-959f-530645077feb"