feat(web): browser terminal gateway (#265)
* feat(web): add authenticated loopback browser terminal gateway * fix(web): publish complete credentials atomically * fix(web): ignore palette clicks before terminal initialization * fix(web): report theme colors for native backdrop dimming * fix(theme): restore neutral session history contrast and gradient * feat(web): scope access tokens to gateway lifetime and support renewal * feat(web): show connected views in a minimal accessible header * fix(web): clarify access recovery and simplify header controls * feat(web): adapt terminal controls and viewport for touch devices * fix(web): verify HTTP readiness before reusing gateway access * feat(web): configure listener and public origin * build(deps): update vev-vt HTML renderer * build(deps): update vev-vt HTML renderer to 55a2b5aa * fix(web): quantize wheel deltas into one report per notch Browsers emit wheel input as a scroll distance, so high-resolution wheels send a burst of micro-events per notch and each one produced an SGR report, scrolling dozens of lines. Accumulate fractional deltas per terminal and emit whole-notch reports in a single write, with Shift held multiplying the count for fast scrolling. * fix(web): coalesce terminal updates to one DOM commit per frame Apply queued browser updates in a single requestAnimationFrame flush, preserving every incremental update in order. A timeout fallback keeps hidden pages live where rAF is throttled to zero. * fix(web): split web control socket per OS for darwin builds * fix(web): address final review findings on gateway hardening Secure the darwin control socket on stale recovery, compare Host case-insensitively with trailing-dot tolerance, guard the license route to GET, fail closed on zero-value probe addresses, keep horizontal-only wheel events from wiping banked vertical scroll, preserve Alt on Shift+Tab, propagate config warnings to the launcher, reuse one readiness client, and fold the wheel mutex into the terminal lock. * fix(web): harden browser adapter update and startup paths Fail closed on invalid terminal updates, validate queued updates, reuse the renderer schema version, guard redundant status writes, distinguish auth failures from unreachable gateways, and keep the access link retryable until login succeeds.
- commit
- db3b9a5b74ba48cc23eb174d16ec64b68e569083
- author
- bnema · · 2026-09-10 05:15 UTC
- committer
- GitHub · · 2026-09-10 05:15 UTC
- parent
- eca6382
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
42 files · +3163 -9
M README.md +7 -0
| @@ -36,6 +36,7 @@ vev ls [<host>|--all] list sessions | |||
| 36 | 36 | vev host add|rm <host> manage remote hosts | |
| 37 | 37 | vev host list list remote hosts | |
| 38 | 38 | vev kill <name>|--all kill sessions | |
| 39 | + | vev --web-daemon start the private browser terminal | |
| 39 | 40 | vev --ui-driver [options] drive a headless attachment over JSONL | |
| 40 | 41 | vev --ui-observe ... expose passive observation for an attach | |
| 41 | 42 | vev --ui-control ... expose observation and input control | |
| @@ -82,6 +83,12 @@ vev attach user@host[:session] | |||
| 82 | 83 | ||
| 83 | 84 | SSH bootstraps an authenticated direct UDP connection that resumes after sleep or network changes. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. The remote host needs vev installed; see [remote resilience](docs/remote-resilience.md) for firewall, transport, and host-list details. | |
| 84 | 85 | ||
| 86 | + | ## Browser terminal | |
| 87 | + | ||
| 88 | + | `vev --web-daemon` starts a background web gateway on `127.0.0.1:8778` and prints a private access link. The browser displays the ordinary multiplexer UI, including its palette, tabs and splits, using HTML/CSS and a small native JavaScript adapter. No frontend framework or external assets are loaded. | |
| 89 | + | ||
| 90 | + | Use `--web-listen` and `--web-origin` (or `web.listen` and `web.origin` in config) for a private network or an HTTPS reverse proxy. VEV serves HTTP; the proxy owns TLS. See [browser terminal](docs/web-terminal.md) for setup, isolated visual testing, controls and current limitations. | |
| 91 | + | ||
| 85 | 92 | ## UI driver | |
| 86 | 93 | ||
| 87 | 94 | `vev --ui-driver` provides bounded capture, key/text input, and deterministic waits for a headless attachment. Existing clients stay unchanged unless `--ui-observe` or `--ui-control` is explicitly enabled. See [UI driver](docs/ui-driver.md) for the JSONL contract, private socket bridge, completion semantics, and isolated endpoint configuration. | |
| @@ -36,6 +36,7 @@ vev ls [<host>|--all] list sessions | |||||
| 36 | vev host add|rm <host> manage remote hosts | 36 | vev host add|rm <host> manage remote hosts | ||
| 37 | vev host list list remote hosts | 37 | vev host list list remote hosts | ||
| 38 | vev kill <name>|--all kill sessions | 38 | vev kill <name>|--all kill sessions | ||
| 39 | + | vev --web-daemon start the private browser terminal | |||
| 39 | vev --ui-driver [options] drive a headless attachment over JSONL | 40 | vev --ui-driver [options] drive a headless attachment over JSONL | ||
| 40 | vev --ui-observe ... expose passive observation for an attach | 41 | vev --ui-observe ... expose passive observation for an attach | ||
| 41 | vev --ui-control ... expose observation and input control | 42 | vev --ui-control ... expose observation and input control | ||
| @@ -82,6 +83,12 @@ vev attach user@host[:session] | |||||
| 82 | 83 | ||||
| 83 | SSH bootstraps an authenticated direct UDP connection that resumes after sleep or network changes. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. The remote host needs vev installed; see [remote resilience](docs/remote-resilience.md) for firewall, transport, and host-list details. | 84 | SSH bootstraps an authenticated direct UDP connection that resumes after sleep or network changes. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. The remote host needs vev installed; see [remote resilience](docs/remote-resilience.md) for firewall, transport, and host-list details. | ||
| 84 | 85 | ||||
| 86 | + | ## Browser terminal | |||
| 87 | + | ||||
| 88 | + | `vev --web-daemon` starts a background web gateway on `127.0.0.1:8778` and prints a private access link. The browser displays the ordinary multiplexer UI, including its palette, tabs and splits, using HTML/CSS and a small native JavaScript adapter. No frontend framework or external assets are loaded. | |||
| 89 | + | ||||
| 90 | + | Use `--web-listen` and `--web-origin` (or `web.listen` and `web.origin` in config) for a private network or an HTTPS reverse proxy. VEV serves HTTP; the proxy owns TLS. See [browser terminal](docs/web-terminal.md) for setup, isolated visual testing, controls and current limitations. | |||
| 91 | + | ||||
| 85 | ## UI driver | 92 | ## UI driver | ||
| 86 | 93 | ||||
| 87 | `vev --ui-driver` provides bounded capture, key/text input, and deterministic waits for a headless attachment. Existing clients stay unchanged unless `--ui-observe` or `--ui-control` is explicitly enabled. See [UI driver](docs/ui-driver.md) for the JSONL contract, private socket bridge, completion semantics, and isolated endpoint configuration. | 94 | `vev --ui-driver` provides bounded capture, key/text input, and deterministic waits for a headless attachment. Existing clients stay unchanged unless `--ui-observe` or `--ui-control` is explicitly enabled. See [UI driver](docs/ui-driver.md) for the JSONL contract, private socket bridge, completion semantics, and isolated endpoint configuration. | ||
M docs/architecture.md +5 -0
| @@ -12,6 +12,7 @@ vev uses a hexagonal core with typed session messages at the client and daemon b | |||
| 12 | 12 | - `internal/usecase`: client, daemon, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. | |
| 13 | 13 | - `internal/adapters/sessionwire`: translates between typed session connections and raw wire transports, including direction checks and decode-failure classification. | |
| 14 | 14 | - `internal/adapters/uidriver`: owns strict JSONL decoding, response serialization, bounded controller queues, private Unix sockets, peer credentials, and the stdio bridge. It consumes `ports.UIService` and never creates an attachment. | |
| 15 | + | - `internal/adapters/webterm`: owns the authenticated loopback HTTP/WebSocket frontend, browser event encoding, VT-backed terminal and transactional HTML output. It implements `ports.Terminal`; application composition supplies the ordinary client runner. | |
| 15 | 16 | - `internal/adapters/uiterm`: owns the immutable VT mirror/snapshot and deterministic headless terminal. The concrete terminal writer (`adapters/term`) taps successful writes and flushes into this sink only when observation is enabled. | |
| 16 | 17 | - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, and observability implementations. | |
| 17 | 18 | - `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, injects typed ports into use cases, and owns explicit UI-driver endpoint launch configuration. | |
| @@ -37,6 +38,10 @@ Headless UI-driver mode composes one normal `client.Runner`, one `uiterm` termin | |||
| 37 | 38 | ||
| 38 | 39 | Interactive observation is a separate opt-in composition. `term.Terminal` remains the owner of raw mode, physical geometry, terminal queries, serialized output writes, and flush success. Its optional observation sink mirrors byte prefixes and successful flush boundaries into `uiterm`; UI transaction methods are delegated through a composition wrapper so semantic context is committed atomically with the physical flush. A private per-client Unix endpoint uses the same `ports.UIService`; `--ui-control` is the only mode that permits input operations. Slow or disconnected observers cannot claim geometry or block the physical terminal. | |
| 39 | 40 | ||
| 41 | + | ## Browser terminal | |
| 42 | + | ||
| 43 | + | `--web-daemon` launches a separate gateway with an inherited HTTP listener, loopback by default. App composition resolves `web.listen`/`web.origin` and CLI overrides before binding. The web adapter validates the configured browser-facing Host and Origin without trusting forwarded headers; a proxy owns TLS. The same-user local control socket returns the running gateway's settings and memory-only credential; readiness probes contact only its local listener. Each authenticated WebSocket owns a normal client runner and a `webterm.Terminal`. The adapter encodes browser events as terminal input, mirrors flushed client output through `vev-vt`, and sends transactional HTML updates. Disconnect cancels only that attachment. The multiplexer remains daemon-owned; browser input does not weaken the restricted UI-driver automation contract. | |
| 44 | + | ||
| 40 | 45 | ## Session composition | |
| 41 | 46 | ||
| 42 | 47 | ```text | |
| @@ -12,6 +12,7 @@ vev uses a hexagonal core with typed session messages at the client and daemon b | |||||
| 12 | - `internal/usecase`: client, daemon, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. | 12 | - `internal/usecase`: client, daemon, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. | ||
| 13 | - `internal/adapters/sessionwire`: translates between typed session connections and raw wire transports, including direction checks and decode-failure classification. | 13 | - `internal/adapters/sessionwire`: translates between typed session connections and raw wire transports, including direction checks and decode-failure classification. | ||
| 14 | - `internal/adapters/uidriver`: owns strict JSONL decoding, response serialization, bounded controller queues, private Unix sockets, peer credentials, and the stdio bridge. It consumes `ports.UIService` and never creates an attachment. | 14 | - `internal/adapters/uidriver`: owns strict JSONL decoding, response serialization, bounded controller queues, private Unix sockets, peer credentials, and the stdio bridge. It consumes `ports.UIService` and never creates an attachment. | ||
| 15 | + | - `internal/adapters/webterm`: owns the authenticated loopback HTTP/WebSocket frontend, browser event encoding, VT-backed terminal and transactional HTML output. It implements `ports.Terminal`; application composition supplies the ordinary client runner. | |||
| 15 | - `internal/adapters/uiterm`: owns the immutable VT mirror/snapshot and deterministic headless terminal. The concrete terminal writer (`adapters/term`) taps successful writes and flushes into this sink only when observation is enabled. | 16 | - `internal/adapters/uiterm`: owns the immutable VT mirror/snapshot and deterministic headless terminal. The concrete terminal writer (`adapters/term`) taps successful writes and flushes into this sink only when observation is enabled. | ||
| 16 | - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, and observability implementations. | 17 | - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, and observability implementations. | ||
| 17 | - `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, injects typed ports into use cases, and owns explicit UI-driver endpoint launch configuration. | 18 | - `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, injects typed ports into use cases, and owns explicit UI-driver endpoint launch configuration. | ||
| @@ -37,6 +38,10 @@ Headless UI-driver mode composes one normal `client.Runner`, one `uiterm` termin | |||||
| 37 | 38 | ||||
| 38 | Interactive observation is a separate opt-in composition. `term.Terminal` remains the owner of raw mode, physical geometry, terminal queries, serialized output writes, and flush success. Its optional observation sink mirrors byte prefixes and successful flush boundaries into `uiterm`; UI transaction methods are delegated through a composition wrapper so semantic context is committed atomically with the physical flush. A private per-client Unix endpoint uses the same `ports.UIService`; `--ui-control` is the only mode that permits input operations. Slow or disconnected observers cannot claim geometry or block the physical terminal. | 39 | Interactive observation is a separate opt-in composition. `term.Terminal` remains the owner of raw mode, physical geometry, terminal queries, serialized output writes, and flush success. Its optional observation sink mirrors byte prefixes and successful flush boundaries into `uiterm`; UI transaction methods are delegated through a composition wrapper so semantic context is committed atomically with the physical flush. A private per-client Unix endpoint uses the same `ports.UIService`; `--ui-control` is the only mode that permits input operations. Slow or disconnected observers cannot claim geometry or block the physical terminal. | ||
| 39 | 40 | ||||
| 41 | + | ## Browser terminal | |||
| 42 | + | ||||
| 43 | + | `--web-daemon` launches a separate gateway with an inherited HTTP listener, loopback by default. App composition resolves `web.listen`/`web.origin` and CLI overrides before binding. The web adapter validates the configured browser-facing Host and Origin without trusting forwarded headers; a proxy owns TLS. The same-user local control socket returns the running gateway's settings and memory-only credential; readiness probes contact only its local listener. Each authenticated WebSocket owns a normal client runner and a `webterm.Terminal`. The adapter encodes browser events as terminal input, mirrors flushed client output through `vev-vt`, and sends transactional HTML updates. Disconnect cancels only that attachment. The multiplexer remains daemon-owned; browser input does not weaken the restricted UI-driver automation contract. | |||
| 44 | + | ||||
| 40 | ## Session composition | 45 | ## Session composition | ||
| 41 | 46 | ||||
| 42 | ```text | 47 | ```text | ||
M docs/configuration.md +5 -1
| @@ -1,8 +1,12 @@ | |||
| 1 | 1 | # Configuration | |
| 2 | 2 | ||
| 3 | - | vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The daemon picks up changes within a couple of seconds; no restart needed. | |
| 3 | + | vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The session daemon picks up changes within a couple of seconds. Browser gateway settings (`web.listen` and `web.origin`) require a gateway restart; flags override their config values. See [browser terminal](web-terminal.md#listener-and-public-origin) for private-network and HTTPS proxy setup. | |
| 4 | 4 | ||
| 5 | 5 | ```text | |
| 6 | + | # Browser gateway: HTTP listener and browser-facing origin, read at startup. | |
| 7 | + | web.listen = 127.0.0.1:8778 | |
| 8 | + | web.origin = http://127.0.0.1:8778 | |
| 9 | + | ||
| 6 | 10 | # Theme: auto follows the client; dark/light use neutral built-in defaults. | |
| 7 | 11 | theme = auto | |
| 8 | 12 | # In auto mode with palette inheritance enabled, infer a terminal accent. | |
| @@ -1,8 +1,12 @@ | |||||
| 1 | # Configuration | 1 | # Configuration | ||
| 2 | 2 | ||||
| 3 | - | vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The daemon picks up changes within a couple of seconds; no restart needed. | 3 | + | vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The session daemon picks up changes within a couple of seconds. Browser gateway settings (`web.listen` and `web.origin`) require a gateway restart; flags override their config values. See [browser terminal](web-terminal.md#listener-and-public-origin) for private-network and HTTPS proxy setup. |
| 4 | 4 | ||||
| 5 | ```text | 5 | ```text | ||
| 6 | + | # Browser gateway: HTTP listener and browser-facing origin, read at startup. | |||
| 7 | + | web.listen = 127.0.0.1:8778 | |||
| 8 | + | web.origin = http://127.0.0.1:8778 | |||
| 9 | + | ||||
| 6 | # Theme: auto follows the client; dark/light use neutral built-in defaults. | 10 | # Theme: auto follows the client; dark/light use neutral built-in defaults. | ||
| 7 | theme = auto | 11 | theme = auto | ||
| 8 | # In auto mode with palette inheritance enabled, infer a terminal accent. | 12 | # In auto mode with palette inheritance enabled, infer a terminal accent. | ||
A docs/web-terminal.md +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | # Browser terminal | |
| 2 | + | ||
| 3 | + | ```sh | |
| 4 | + | vev --web-daemon | |
| 5 | + | ``` | |
| 6 | + | ||
| 7 | + | The command starts a detached web gateway on **127.0.0.1:8778** and prints a private access link. Open that link in a browser. Running the command again with the same settings prints the same link when that gateway is reachable. Another process or development profile using the selected address and port prevents startup. | |
| 8 | + | ||
| 9 | + | The gateway connects ordinary client attachments to the vev daemon. The daemon owns sessions, tabs, panes, palettes and PTYs. Each browser connection has its own terminal view. Closing the page detaches that view without killing its session shells. Reloading or choosing Reconnect creates a new ephemeral session; previous sessions remain available through the session picker (`SSP`). Repeated reloads can therefore accumulate shells: close unwanted sessions explicitly. Automatic reattachment is not implemented, and input is never automatically replayed. | |
| 10 | + | ||
| 11 | + | ## Listener and public origin | |
| 12 | + | ||
| 13 | + | The gateway serves HTTP. A reverse proxy, such as a NetBird app, owns HTTPS and certificates. For a proxy on the same machine: | |
| 14 | + | ||
| 15 | + | ```sh | |
| 16 | + | vev --web-daemon --web-listen 127.0.0.1:8778 \ | |
| 17 | + | --web-origin https://terminal.example.internal | |
| 18 | + | ``` | |
| 19 | + | ||
| 20 | + | Equivalent persistent settings in vev's flat configuration file (`$XDG_CONFIG_HOME/vev/config`, or `~/.config/vev/config`): | |
| 21 | + | ||
| 22 | + | ```ini | |
| 23 | + | web.listen = 127.0.0.1:8778 | |
| 24 | + | web.origin = https://terminal.example.internal | |
| 25 | + | ``` | |
| 26 | + | ||
| 27 | + | Flags override individual config values. Settings apply at gateway startup, not through live config reload. To change a running gateway's settings, stop that gateway and start it again; session shells survive. Token renewal uses the running gateway's origin even if the config file changes. | |
| 28 | + | ||
| 29 | + | - `web.listen` accepts a literal IPv4 or bracketed IPv6 address and port, for example `100.64.0.10:8778` or `[::1]:8778`. Select the local NetBird IP when the proxy reaches VEV through NetBird. Interface names and DNS names are not listener addresses. | |
| 30 | + | - `web.origin` is the browser-facing HTTP(S) origin, including a non-default port if needed. Paths, credentials, query strings and fragments are not accepted. A trailing `/` and default ports are normalized. | |
| 31 | + | - Without settings, VEV uses `127.0.0.1:8778` and `http://127.0.0.1:8778`. A loopback listener can derive its HTTP origin; any non-loopback or wildcard listener requires an explicit origin. | |
| 32 | + | - The proxy must preserve the public **Host** and browser **Origin**, forward WebSocket upgrades, and route the entire origin to VEV without a path prefix. VEV ignores `Forwarded` and `X-Forwarded-*`. HTTPS origins use `Secure` cookies and `wss://` in the browser. | |
| 33 | + | ||
| 34 | + | Prefer loopback for a co-located proxy, or one specific private IP. `0.0.0.0` and `[::]` deliberately expose broader interfaces; use firewall and NetBird access rules to restrict reachability. HTTP access on a private encrypted network is possible, but remote HTTP does not provide a browser secure context. VEV neither provisions TLS nor verifies your proxy or network policy. Its readiness probe checks the local HTTP listener, not public DNS, certificates or proxy routing. | |
| 35 | + | ||
| 36 | + | ## Controls | |
| 37 | + | ||
| 38 | + | The header shows a connection-state indicator and the number of connected browser terminal views, refreshed every two seconds. Background session shells are not counted. Button icons come from Lucide (ISC license, available at `/icons-license`). | |
| 39 | + | ||
| 40 | + | - Type directly into the terminal, including composed text and pasted plain text. | |
| 41 | + | - Use **Alt+Space**, or the **Palette** button, for commands such as `CNT`, `SPR`, `SPD` and `SSP`. | |
| 42 | + | - Use **Alt+h/j/k/l** to focus panes and **Alt+1…9** to switch tabs. | |
| 43 | + | - Click panes to focus them. Mouse reports and wheel events follow the multiplexer input path. One wheel notch sends one wheel report; hold **Shift** for ×10 fast scrolling. | |
| 44 | + | - The grid fits the browser window, up to 512 columns and 256 rows. | |
| 45 | + | - On touch devices, **Select** preserves native text selection without terminal mouse capture; **Keys** enables interaction and opens the keyboard. The initial view does not force the keyboard open. | |
| 46 | + | - **Ctrl+Shift+F6** moves focus out of terminal input to the Palette button for keyboard navigation. | |
| 47 | + | ||
| 48 | + | Touch controls have 44px targets. The layout accounts for safe areas and the visual viewport while preserving pinch zoom. Real-device keyboard, composition and selection behavior still requires validation on iOS Safari and Android Chrome; desktop device emulation is not a substitute. Browser scrollback and touch-to-terminal scrolling are not implemented. | |
| 49 | + | ||
| 50 | + | Browser and window-manager shortcuts can intercept keys before vev receives them. The Palette button avoids the common Alt+Space window-menu conflict. Native clipboard shortcuts remain available; terminal copy-mode clipboard export is not implemented by the web adapter. | |
| 51 | + | ||
| 52 | + | ## Isolated visual testing | |
| 53 | + | ||
| 54 | + | From a source checkout: | |
| 55 | + | ||
| 56 | + | ```sh | |
| 57 | + | go build -o .dev/vev-web . | |
| 58 | + | env VEV_ENV=web-visual .dev/vev-web --web-daemon | |
| 59 | + | ``` | |
| 60 | + | ||
| 61 | + | This uses the checkout's `.dev/web-visual` configuration, state and runtime directories instead of the normal installation. Keep the printed credential out of screenshots and reports. To inspect these test sessions from a physical terminal: | |
| 62 | + | ||
| 63 | + | ```sh | |
| 64 | + | env VEV_ENV=web-visual .dev/vev-web ls | |
| 65 | + | ``` | |
| 66 | + | ||
| 67 | + | Useful visual checks: palette search, horizontal and vertical splits, tab switching, pane focus, shell input, full-screen applications, paste, scrolling, window resize and reload. Record browser/version, viewport size, action and expected result when reporting a problem. | |
| 68 | + | ||
| 69 | + | `scripts/web-visual-smoke.cjs` exercises the local gateway with an installed Playwright module. Set `PLAYWRIGHT_MODULE` to that module's absolute path, `VEV_BINARY` to the built executable and `VEV_ENV=web-visual`, and optionally `CHROMIUM_PATH` to an installed Chromium executable. It creates test sessions and panes; run it only against an isolated profile. `scripts/web-access-smoke.cjs` uses the same variables to check the view counter and token rotation; it revokes all browser access on that isolated gateway. | |
| 70 | + | ||
| 71 | + | ## Security and implementation | |
| 72 | + | ||
| 73 | + | The listener binds IPv4 loopback by default. Network exposure requires explicit configuration. A fresh random 256-bit credential lives only in gateway memory and expires when that process stops. `vev --web-renew-token` replaces it on a running gateway, invalidates old links and cookies, disconnects browser views, and prints the new link without stopping session shells. The CLI retrieves or renews access through a profile-scoped local control Unix socket restricted by same-user peer credentials (Linux abstract socket; private 0600 socket file on macOS); no credential file is written. The access link carries it in a URL fragment; the page removes the fragment and exchanges it for an HttpOnly SameSite cookie. HTTP Host and Origin checks, authenticated WebSockets, a restrictive Content Security Policy, bounded input and connection limits protect the local terminal endpoint. Do not publish the credential. Treat access to this endpoint as access to your shell; restrict any proxy and upstream listener to trusted clients. | |
| 74 | + | ||
| 75 | + | The browser uses the dependency-free `vev-vt` DOM renderer and a small native JavaScript transport adapter. Go owns rendering state and terminal input encoding. Assets are embedded in the binary; there is no CDN, bundler, HTMX or frontend framework. | |
| 76 | + | ||
| 77 | + | The web gateway is a separate long-lived process; stopping the session daemon does not stop the gateway. Send SIGTERM to the gateway process to stop it. The initial web frontend does not render Kitty graphics, export OSC 52 clipboard requests or guarantee full grapheme/emoji fidelity. Remote session navigation uses the existing client path but is not yet covered by the browser visual smoke checks. | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | # Browser terminal | |||
| 2 | + | ||||
| 3 | + | ```sh | |||
| 4 | + | vev --web-daemon | |||
| 5 | + | ``` | |||
| 6 | + | ||||
| 7 | + | The command starts a detached web gateway on **127.0.0.1:8778** and prints a private access link. Open that link in a browser. Running the command again with the same settings prints the same link when that gateway is reachable. Another process or development profile using the selected address and port prevents startup. | |||
| 8 | + | ||||
| 9 | + | The gateway connects ordinary client attachments to the vev daemon. The daemon owns sessions, tabs, panes, palettes and PTYs. Each browser connection has its own terminal view. Closing the page detaches that view without killing its session shells. Reloading or choosing Reconnect creates a new ephemeral session; previous sessions remain available through the session picker (`SSP`). Repeated reloads can therefore accumulate shells: close unwanted sessions explicitly. Automatic reattachment is not implemented, and input is never automatically replayed. | |||
| 10 | + | ||||
| 11 | + | ## Listener and public origin | |||
| 12 | + | ||||
| 13 | + | The gateway serves HTTP. A reverse proxy, such as a NetBird app, owns HTTPS and certificates. For a proxy on the same machine: | |||
| 14 | + | ||||
| 15 | + | ```sh | |||
| 16 | + | vev --web-daemon --web-listen 127.0.0.1:8778 \ | |||
| 17 | + | --web-origin https://terminal.example.internal | |||
| 18 | + | ``` | |||
| 19 | + | ||||
| 20 | + | Equivalent persistent settings in vev's flat configuration file (`$XDG_CONFIG_HOME/vev/config`, or `~/.config/vev/config`): | |||
| 21 | + | ||||
| 22 | + | ```ini | |||
| 23 | + | web.listen = 127.0.0.1:8778 | |||
| 24 | + | web.origin = https://terminal.example.internal | |||
| 25 | + | ``` | |||
| 26 | + | ||||
| 27 | + | Flags override individual config values. Settings apply at gateway startup, not through live config reload. To change a running gateway's settings, stop that gateway and start it again; session shells survive. Token renewal uses the running gateway's origin even if the config file changes. | |||
| 28 | + | ||||
| 29 | + | - `web.listen` accepts a literal IPv4 or bracketed IPv6 address and port, for example `100.64.0.10:8778` or `[::1]:8778`. Select the local NetBird IP when the proxy reaches VEV through NetBird. Interface names and DNS names are not listener addresses. | |||
| 30 | + | - `web.origin` is the browser-facing HTTP(S) origin, including a non-default port if needed. Paths, credentials, query strings and fragments are not accepted. A trailing `/` and default ports are normalized. | |||
| 31 | + | - Without settings, VEV uses `127.0.0.1:8778` and `http://127.0.0.1:8778`. A loopback listener can derive its HTTP origin; any non-loopback or wildcard listener requires an explicit origin. | |||
| 32 | + | - The proxy must preserve the public **Host** and browser **Origin**, forward WebSocket upgrades, and route the entire origin to VEV without a path prefix. VEV ignores `Forwarded` and `X-Forwarded-*`. HTTPS origins use `Secure` cookies and `wss://` in the browser. | |||
| 33 | + | ||||
| 34 | + | Prefer loopback for a co-located proxy, or one specific private IP. `0.0.0.0` and `[::]` deliberately expose broader interfaces; use firewall and NetBird access rules to restrict reachability. HTTP access on a private encrypted network is possible, but remote HTTP does not provide a browser secure context. VEV neither provisions TLS nor verifies your proxy or network policy. Its readiness probe checks the local HTTP listener, not public DNS, certificates or proxy routing. | |||
| 35 | + | ||||
| 36 | + | ## Controls | |||
| 37 | + | ||||
| 38 | + | The header shows a connection-state indicator and the number of connected browser terminal views, refreshed every two seconds. Background session shells are not counted. Button icons come from Lucide (ISC license, available at `/icons-license`). | |||
| 39 | + | ||||
| 40 | + | - Type directly into the terminal, including composed text and pasted plain text. | |||
| 41 | + | - Use **Alt+Space**, or the **Palette** button, for commands such as `CNT`, `SPR`, `SPD` and `SSP`. | |||
| 42 | + | - Use **Alt+h/j/k/l** to focus panes and **Alt+1…9** to switch tabs. | |||
| 43 | + | - Click panes to focus them. Mouse reports and wheel events follow the multiplexer input path. One wheel notch sends one wheel report; hold **Shift** for ×10 fast scrolling. | |||
| 44 | + | - The grid fits the browser window, up to 512 columns and 256 rows. | |||
| 45 | + | - On touch devices, **Select** preserves native text selection without terminal mouse capture; **Keys** enables interaction and opens the keyboard. The initial view does not force the keyboard open. | |||
| 46 | + | - **Ctrl+Shift+F6** moves focus out of terminal input to the Palette button for keyboard navigation. | |||
| 47 | + | ||||
| 48 | + | Touch controls have 44px targets. The layout accounts for safe areas and the visual viewport while preserving pinch zoom. Real-device keyboard, composition and selection behavior still requires validation on iOS Safari and Android Chrome; desktop device emulation is not a substitute. Browser scrollback and touch-to-terminal scrolling are not implemented. | |||
| 49 | + | ||||
| 50 | + | Browser and window-manager shortcuts can intercept keys before vev receives them. The Palette button avoids the common Alt+Space window-menu conflict. Native clipboard shortcuts remain available; terminal copy-mode clipboard export is not implemented by the web adapter. | |||
| 51 | + | ||||
| 52 | + | ## Isolated visual testing | |||
| 53 | + | ||||
| 54 | + | From a source checkout: | |||
| 55 | + | ||||
| 56 | + | ```sh | |||
| 57 | + | go build -o .dev/vev-web . | |||
| 58 | + | env VEV_ENV=web-visual .dev/vev-web --web-daemon | |||
| 59 | + | ``` | |||
| 60 | + | ||||
| 61 | + | This uses the checkout's `.dev/web-visual` configuration, state and runtime directories instead of the normal installation. Keep the printed credential out of screenshots and reports. To inspect these test sessions from a physical terminal: | |||
| 62 | + | ||||
| 63 | + | ```sh | |||
| 64 | + | env VEV_ENV=web-visual .dev/vev-web ls | |||
| 65 | + | ``` | |||
| 66 | + | ||||
| 67 | + | Useful visual checks: palette search, horizontal and vertical splits, tab switching, pane focus, shell input, full-screen applications, paste, scrolling, window resize and reload. Record browser/version, viewport size, action and expected result when reporting a problem. | |||
| 68 | + | ||||
| 69 | + | `scripts/web-visual-smoke.cjs` exercises the local gateway with an installed Playwright module. Set `PLAYWRIGHT_MODULE` to that module's absolute path, `VEV_BINARY` to the built executable and `VEV_ENV=web-visual`, and optionally `CHROMIUM_PATH` to an installed Chromium executable. It creates test sessions and panes; run it only against an isolated profile. `scripts/web-access-smoke.cjs` uses the same variables to check the view counter and token rotation; it revokes all browser access on that isolated gateway. | |||
| 70 | + | ||||
| 71 | + | ## Security and implementation | |||
| 72 | + | ||||
| 73 | + | The listener binds IPv4 loopback by default. Network exposure requires explicit configuration. A fresh random 256-bit credential lives only in gateway memory and expires when that process stops. `vev --web-renew-token` replaces it on a running gateway, invalidates old links and cookies, disconnects browser views, and prints the new link without stopping session shells. The CLI retrieves or renews access through a profile-scoped local control Unix socket restricted by same-user peer credentials (Linux abstract socket; private 0600 socket file on macOS); no credential file is written. The access link carries it in a URL fragment; the page removes the fragment and exchanges it for an HttpOnly SameSite cookie. HTTP Host and Origin checks, authenticated WebSockets, a restrictive Content Security Policy, bounded input and connection limits protect the local terminal endpoint. Do not publish the credential. Treat access to this endpoint as access to your shell; restrict any proxy and upstream listener to trusted clients. | |||
| 74 | + | ||||
| 75 | + | The browser uses the dependency-free `vev-vt` DOM renderer and a small native JavaScript transport adapter. Go owns rendering state and terminal input encoding. Assets are embedded in the binary; there is no CDN, bundler, HTMX or frontend framework. | |||
| 76 | + | ||||
| 77 | + | The web gateway is a separate long-lived process; stopping the session daemon does not stop the gateway. Send SIGTERM to the gateway process to stop it. The initial web frontend does not render Kitty graphics, export OSC 52 clipboard requests or guarantee full grapheme/emoji fidelity. Remote session navigation uses the existing client path but is not yet covered by the browser visual smoke checks. | |||
M go.mod +2 -1
| @@ -3,7 +3,8 @@ module github.com/bnema/vev | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/vev-vt v0.5.2-0.20260906085439-e69c4686b8bb | |
| 6 | + | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a | |
| 7 | + | github.com/coder/websocket v1.8.15 | |
| 7 | 8 | github.com/stretchr/testify v1.11.1 | |
| 8 | 9 | golang.org/x/sys v0.42.0 | |
| 9 | 10 | ) | |
| @@ -3,7 +3,8 @@ module github.com/bnema/vev | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/vev-vt v0.5.2-0.20260906085439-e69c4686b8bb | 6 | + | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a |
| 7 | + | github.com/coder/websocket v1.8.15 | |||
| 7 | github.com/stretchr/testify v1.11.1 | 8 | github.com/stretchr/testify v1.11.1 | ||
| 8 | golang.org/x/sys v0.42.0 | 9 | golang.org/x/sys v0.42.0 | ||
| 9 | ) | 10 | ) | ||
M go.sum +6 -2
| @@ -1,5 +1,9 @@ | |||
| 1 | - | github.com/bnema/vev-vt v0.5.2-0.20260906085439-e69c4686b8bb h1:WxEsAsS9bCVDgTCdBDMOsbZhOe4NtlDntCVvAt+YPVA= | |
| 2 | - | github.com/bnema/vev-vt v0.5.2-0.20260906085439-e69c4686b8bb/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | |
| 1 | + | github.com/bnema/vev-vt v0.5.2-0.20260909120837-2eca6e73b915 h1:x1DwndNSMPMsl0bWlWos8L/qFA4wuts9sGSYidY2kbQ= | |
| 2 | + | github.com/bnema/vev-vt v0.5.2-0.20260909120837-2eca6e73b915/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | |
| 3 | + | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a h1:n25PbH+nlilKwrNHzR9OJjiKsAZDbDuSQzE/GDtArUY= | |
| 4 | + | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | |
| 5 | + | github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= | |
| 6 | + | github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= | |
| 3 | 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | |
| 4 | 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 5 | 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |
| @@ -1,5 +1,9 @@ | |||||
| 1 | - | github.com/bnema/vev-vt v0.5.2-0.20260906085439-e69c4686b8bb h1:WxEsAsS9bCVDgTCdBDMOsbZhOe4NtlDntCVvAt+YPVA= | 1 | + | github.com/bnema/vev-vt v0.5.2-0.20260909120837-2eca6e73b915 h1:x1DwndNSMPMsl0bWlWos8L/qFA4wuts9sGSYidY2kbQ= |
| 2 | - | github.com/bnema/vev-vt v0.5.2-0.20260906085439-e69c4686b8bb/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | 2 | + | github.com/bnema/vev-vt v0.5.2-0.20260909120837-2eca6e73b915/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= |
| 3 | + | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a h1:n25PbH+nlilKwrNHzR9OJjiKsAZDbDuSQzE/GDtArUY= | |||
| 4 | + | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a/go.mod h1:mBdVV+ALtBgPNVHPrxY50S+d1ZO/sDn0AA7EtJf00M4= | |||
| 5 | + | github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= | |||
| 6 | + | github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= | |||
| 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
M internal/adapters/config/config.go +9 -0
| @@ -36,6 +36,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 36 | 36 | seenNavKeys := make(map[string]bool) | |
| 37 | 37 | seenTabsKeys := make(map[string]bool) | |
| 38 | 38 | seenScrollbackKeys := make(map[string]bool) | |
| 39 | + | seenWebKeys := make(map[string]bool) | |
| 39 | 40 | ||
| 40 | 41 | scanner := bufio.NewScanner(r) | |
| 41 | 42 | lineNo := 0 | |
| @@ -63,6 +64,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 63 | 64 | } | |
| 64 | 65 | ||
| 65 | 66 | switch { | |
| 67 | + | case key == "web.listen" || key == "web.origin": | |
| 68 | + | warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo) | |
| 69 | + | // Keep raw values for fail-closed validation at gateway startup. | |
| 70 | + | if key == "web.listen" { | |
| 71 | + | cfg.WebListen = value | |
| 72 | + | } else { | |
| 73 | + | cfg.WebOrigin = value | |
| 74 | + | } | |
| 66 | 75 | case key == "theme": | |
| 67 | 76 | mode, ok := parseTheme(value) | |
| 68 | 77 | if !ok { | |
| @@ -36,6 +36,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 36 | seenNavKeys := make(map[string]bool) | 36 | seenNavKeys := make(map[string]bool) | ||
| 37 | seenTabsKeys := make(map[string]bool) | 37 | seenTabsKeys := make(map[string]bool) | ||
| 38 | seenScrollbackKeys := make(map[string]bool) | 38 | seenScrollbackKeys := make(map[string]bool) | ||
| 39 | + | seenWebKeys := make(map[string]bool) | |||
| 39 | 40 | ||||
| 40 | scanner := bufio.NewScanner(r) | 41 | scanner := bufio.NewScanner(r) | ||
| 41 | lineNo := 0 | 42 | lineNo := 0 | ||
| @@ -63,6 +64,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 63 | } | 64 | } | ||
| 64 | 65 | ||||
| 65 | switch { | 66 | switch { | ||
| 67 | + | case key == "web.listen" || key == "web.origin": | |||
| 68 | + | warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo) | |||
| 69 | + | // Keep raw values for fail-closed validation at gateway startup. | |||
| 70 | + | if key == "web.listen" { | |||
| 71 | + | cfg.WebListen = value | |||
| 72 | + | } else { | |||
| 73 | + | cfg.WebOrigin = value | |||
| 74 | + | } | |||
| 66 | case key == "theme": | 75 | case key == "theme": | ||
| 67 | mode, ok := parseTheme(value) | 76 | mode, ok := parseTheme(value) | ||
| 68 | if !ok { | 77 | if !ok { | ||
M internal/adapters/uiterm/terminal.go +1 -2
| @@ -366,12 +366,11 @@ func convertSnapshot(screen vt.ScreenSnapshot, geometry domain.Geometry, context | |||
| 366 | 366 | } | |
| 367 | 367 | } | |
| 368 | 368 | cursor := screen.Cursor() | |
| 369 | - | modes := screen.Modes() | |
| 370 | 369 | return ports.UISnapshot{ | |
| 371 | 370 | Revision: revision, Context: context, | |
| 372 | 371 | Columns: geometry.Cols, Rows: geometry.Rows, | |
| 373 | 372 | Cursor: ports.UICursor{Row: cursor.Row, Column: cursor.Col, Visible: cursor.Visible, Style: cursor.Style, StyleSet: cursor.StyleSet}, | |
| 374 | - | Cells: cells, AutoWrap: modes.AutoWrap, ApplicationCursor: modes.ApplicationCursor, | |
| 373 | + | Cells: cells, AutoWrap: screen.AutoWrapMode(), ApplicationCursor: screen.ApplicationCursorMode(), | |
| 375 | 374 | } | |
| 376 | 375 | } | |
| 377 | 376 | ||
| @@ -366,12 +366,11 @@ func convertSnapshot(screen vt.ScreenSnapshot, geometry domain.Geometry, context | |||||
| 366 | } | 366 | } | ||
| 367 | } | 367 | } | ||
| 368 | cursor := screen.Cursor() | 368 | cursor := screen.Cursor() | ||
| 369 | - | modes := screen.Modes() | |||
| 370 | return ports.UISnapshot{ | 369 | return ports.UISnapshot{ | ||
| 371 | Revision: revision, Context: context, | 370 | Revision: revision, Context: context, | ||
| 372 | Columns: geometry.Cols, Rows: geometry.Rows, | 371 | Columns: geometry.Cols, Rows: geometry.Rows, | ||
| 373 | Cursor: ports.UICursor{Row: cursor.Row, Column: cursor.Col, Visible: cursor.Visible, Style: cursor.Style, StyleSet: cursor.StyleSet}, | 372 | Cursor: ports.UICursor{Row: cursor.Row, Column: cursor.Col, Visible: cursor.Visible, Style: cursor.Style, StyleSet: cursor.StyleSet}, | ||
| 374 | - | Cells: cells, AutoWrap: modes.AutoWrap, ApplicationCursor: modes.ApplicationCursor, | 373 | + | Cells: cells, AutoWrap: screen.AutoWrapMode(), ApplicationCursor: screen.ApplicationCursorMode(), |
| 375 | } | 374 | } | ||
| 376 | } | 375 | } | ||
| 377 | 376 | ||||
A internal/adapters/webterm/ICONS-LICENSE +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | Lucide icons: user-round, square-terminal, rotate-cw | |
| 2 | + | Source: https://github.com/lucide-icons/lucide | |
| 3 | + | ||
| 4 | + | ISC License | |
| 5 | + | ||
| 6 | + | Copyright (c) 2026 Lucide Icons and Contributors | |
| 7 | + | ||
| 8 | + | Permission to use, copy, modify, and/or distribute this software for any | |
| 9 | + | purpose with or without fee is hereby granted, provided that the above | |
| 10 | + | copyright notice and this permission notice appear in all copies. | |
| 11 | + | ||
| 12 | + | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | |
| 13 | + | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF | |
| 14 | + | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR | |
| 15 | + | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES | |
| 16 | + | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN | |
| 17 | + | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF | |
| 18 | + | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | Lucide icons: user-round, square-terminal, rotate-cw | |||
| 2 | + | Source: https://github.com/lucide-icons/lucide | |||
| 3 | + | ||||
| 4 | + | ISC License | |||
| 5 | + | ||||
| 6 | + | Copyright (c) 2026 Lucide Icons and Contributors | |||
| 7 | + | ||||
| 8 | + | Permission to use, copy, modify, and/or distribute this software for any | |||
| 9 | + | purpose with or without fee is hereby granted, provided that the above | |||
| 10 | + | copyright notice and this permission notice appear in all copies. | |||
| 11 | + | ||||
| 12 | + | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | |||
| 13 | + | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF | |||
| 14 | + | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR | |||
| 15 | + | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES | |||
| 16 | + | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN | |||
| 17 | + | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF | |||
| 18 | + | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |||
A internal/adapters/webterm/app.css +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | :root { color-scheme: dark; font-family: ui-monospace, monospace; background: #101010; color: #d8d8d8; } | |
| 2 | + | * { box-sizing: border-box; } | |
| 3 | + | body { margin: 0; height: 100vh; height: var(--app-height, 100dvh); padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); display: flex; flex-direction: column; overflow: hidden; } | |
| 4 | + | header { flex-shrink: 0; display: flex; align-items: center; gap: .75rem; padding: 2px 6px; min-height: 24px; border-bottom: 1px solid #303030; font-size: 12px; } | |
| 5 | + | header strong { color: inherit; font-size: inherit; } | |
| 6 | + | .header-space { flex: 1; } | |
| 7 | + | .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } | |
| 8 | + | header svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; vertical-align: middle; } | |
| 9 | + | #views svg { width: 14px; height: 14px; } | |
| 10 | + | #views { display: inline-flex; align-items: center; gap: 4px; color: #aaa; font-variant-numeric: tabular-nums; } | |
| 11 | + | #connection i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #d9ad52; } | |
| 12 | + | #connection[data-state="connected"] i { background: #86b98b; } | |
| 13 | + | #connection[data-state="disconnected"] i { background: #888; } | |
| 14 | + | #connection[data-state="error"] i { background: #db7777; } | |
| 15 | + | button { font: inherit; color: #aaa; background: transparent; border: 0; border-radius: 0; padding: 2px 6px; cursor: pointer; } | |
| 16 | + | button:hover { color: #fff; } | |
| 17 | + | button:focus-visible { outline: 1px solid #ccc; outline-offset: 1px; } | |
| 18 | + | #workspace { flex: 1; min-height: 0; overflow: hidden; padding: 4px; } | |
| 19 | + | #terminal { display: inline-block; vertical-align: top; --vev-fg: #d8d8d8; --vev-bg: #101010; --vev-cursor: #ccc; --vev-selection-bg: #444; --vev-selection-fg: #fff; } | |
| 20 | + | /* Fixed-size rows do not need to relayout the rest of the terminal. */ | |
| 21 | + | #terminal .vev-terminal__row { contain: strict; transform: translateZ(0); } | |
| 22 | + | #terminal:focus-within { outline: 1px solid #777; outline-offset: -1px; } | |
| 23 | + | .touch-control { display: none; } | |
| 24 | + | @media (pointer: coarse) { | |
| 25 | + | header button { min-width: 44px; min-height: 44px; } | |
| 26 | + | header { gap: .25rem; } | |
| 27 | + | .touch-control { display: inline-block; } | |
| 28 | + | } | |
| 29 | + | button[aria-pressed="true"] { color: #fff; } | |
| 30 | + | #terminal.vev-terminal--mouse-capture { user-select: text; } | |
| 31 | + | #help, noscript { padding: 1rem; } | |
| 32 | + | [hidden] { display: none !important; } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | :root { color-scheme: dark; font-family: ui-monospace, monospace; background: #101010; color: #d8d8d8; } | |||
| 2 | + | * { box-sizing: border-box; } | |||
| 3 | + | body { margin: 0; height: 100vh; height: var(--app-height, 100dvh); padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); display: flex; flex-direction: column; overflow: hidden; } | |||
| 4 | + | header { flex-shrink: 0; display: flex; align-items: center; gap: .75rem; padding: 2px 6px; min-height: 24px; border-bottom: 1px solid #303030; font-size: 12px; } | |||
| 5 | + | header strong { color: inherit; font-size: inherit; } | |||
| 6 | + | .header-space { flex: 1; } | |||
| 7 | + | .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } | |||
| 8 | + | header svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; vertical-align: middle; } | |||
| 9 | + | #views svg { width: 14px; height: 14px; } | |||
| 10 | + | #views { display: inline-flex; align-items: center; gap: 4px; color: #aaa; font-variant-numeric: tabular-nums; } | |||
| 11 | + | #connection i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #d9ad52; } | |||
| 12 | + | #connection[data-state="connected"] i { background: #86b98b; } | |||
| 13 | + | #connection[data-state="disconnected"] i { background: #888; } | |||
| 14 | + | #connection[data-state="error"] i { background: #db7777; } | |||
| 15 | + | button { font: inherit; color: #aaa; background: transparent; border: 0; border-radius: 0; padding: 2px 6px; cursor: pointer; } | |||
| 16 | + | button:hover { color: #fff; } | |||
| 17 | + | button:focus-visible { outline: 1px solid #ccc; outline-offset: 1px; } | |||
| 18 | + | #workspace { flex: 1; min-height: 0; overflow: hidden; padding: 4px; } | |||
| 19 | + | #terminal { display: inline-block; vertical-align: top; --vev-fg: #d8d8d8; --vev-bg: #101010; --vev-cursor: #ccc; --vev-selection-bg: #444; --vev-selection-fg: #fff; } | |||
| 20 | + | /* Fixed-size rows do not need to relayout the rest of the terminal. */ | |||
| 21 | + | #terminal .vev-terminal__row { contain: strict; transform: translateZ(0); } | |||
| 22 | + | #terminal:focus-within { outline: 1px solid #777; outline-offset: -1px; } | |||
| 23 | + | .touch-control { display: none; } | |||
| 24 | + | @media (pointer: coarse) { | |||
| 25 | + | header button { min-width: 44px; min-height: 44px; } | |||
| 26 | + | header { gap: .25rem; } | |||
| 27 | + | .touch-control { display: inline-block; } | |||
| 28 | + | } | |||
| 29 | + | button[aria-pressed="true"] { color: #fff; } | |||
| 30 | + | #terminal.vev-terminal--mouse-capture { user-select: text; } | |||
| 31 | + | #help, noscript { padding: 1rem; } | |||
| 32 | + | [hidden] { display: none !important; } | |||
A internal/adapters/webterm/app.js +225 -0
| @@ -0,0 +1,225 @@ | |||
| 1 | + | (() => { | |
| 2 | + | 'use strict'; | |
| 3 | + | const root = document.querySelector('#terminal'); | |
| 4 | + | const workspace = document.querySelector('#workspace'); | |
| 5 | + | const status = document.querySelector('#status'); | |
| 6 | + | const reconnect = document.querySelector('#reconnect'); | |
| 7 | + | let socket; | |
| 8 | + | let terminal; | |
| 9 | + | let mouse = false; | |
| 10 | + | let lastGeometry = ''; | |
| 11 | + | let fitting = 0; | |
| 12 | + | let received = false; | |
| 13 | + | const coarsePointer = matchMedia('(pointer: coarse)'); | |
| 14 | + | let selecting = coarsePointer.matches; | |
| 15 | + | const selectButton = document.querySelector('#select-mode'); | |
| 16 | + | function setSelecting(value) { | |
| 17 | + | selecting = value; | |
| 18 | + | selectButton.setAttribute('aria-pressed', String(value)); | |
| 19 | + | if (terminal) terminal.setMouseCapture(mouse && !selecting); | |
| 20 | + | if (value) root.querySelector('textarea')?.blur(); | |
| 21 | + | } | |
| 22 | + | setSelecting(selecting); | |
| 23 | + | selectButton.addEventListener('click', () => setSelecting(!selecting)); | |
| 24 | + | document.querySelector('#keyboard').addEventListener('click', () => { | |
| 25 | + | setSelecting(false); | |
| 26 | + | terminal?.focus(); | |
| 27 | + | }); | |
| 28 | + | function fitVisualViewport() { | |
| 29 | + | const viewport = window.visualViewport; | |
| 30 | + | // Pinch zoom must not resize the PTY or fight the browser's pan gesture. | |
| 31 | + | if (!viewport || Math.abs(viewport.scale - 1) > 0.01) return; | |
| 32 | + | document.body.style.setProperty('--app-height', `${viewport.height}px`); | |
| 33 | + | window.scrollTo(0, 0); | |
| 34 | + | fit(); | |
| 35 | + | } | |
| 36 | + | window.visualViewport?.addEventListener('resize', fitVisualViewport); | |
| 37 | + | fitVisualViewport(); | |
| 38 | + | ||
| 39 | + | function syncStatus() { | |
| 40 | + | const text = status.textContent; | |
| 41 | + | const indicator = document.querySelector('#connection'); | |
| 42 | + | indicator.title = text; | |
| 43 | + | indicator.dataset.state = text === 'Connected' ? 'connected' : text === 'Connecting…' ? 'connecting' : text.startsWith('Disconnected') ? 'disconnected' : 'error'; | |
| 44 | + | } | |
| 45 | + | new MutationObserver(syncStatus).observe(status, { childList: true, characterData: true, subtree: true }); | |
| 46 | + | function setViewCount(count = null) { | |
| 47 | + | document.querySelector('#view-count').textContent = count === null ? '—' : String(count); | |
| 48 | + | document.querySelector('#view-description').textContent = `Connected browser terminal views: ${count === null ? 'unknown' : count}`; | |
| 49 | + | } | |
| 50 | + | let counting = false; | |
| 51 | + | async function refreshViews() { | |
| 52 | + | if (counting) return; | |
| 53 | + | if (!socket || socket.readyState !== WebSocket.OPEN) { | |
| 54 | + | setViewCount(); | |
| 55 | + | return; | |
| 56 | + | } | |
| 57 | + | counting = true; | |
| 58 | + | const current = socket; | |
| 59 | + | try { | |
| 60 | + | const response = await fetch('/views', { signal: AbortSignal.timeout(3000) }); | |
| 61 | + | if (!response.ok) throw new Error('unavailable'); | |
| 62 | + | const { active } = await response.json(); | |
| 63 | + | if (!Number.isInteger(active) || active < 0) throw new Error('invalid count'); | |
| 64 | + | if (socket === current && current.readyState === WebSocket.OPEN) setViewCount(active); | |
| 65 | + | } catch { | |
| 66 | + | if (socket === current) setViewCount(); | |
| 67 | + | } finally { counting = false; } | |
| 68 | + | } | |
| 69 | + | setInterval(refreshViews, 2000); | |
| 70 | + | ||
| 71 | + | function send(event) { | |
| 72 | + | if (!socket || socket.readyState !== WebSocket.OPEN) return; | |
| 73 | + | if (socket.bufferedAmount > 1 << 20) { | |
| 74 | + | status.textContent = 'Connection too slow — input stopped. Reconnect to continue.'; | |
| 75 | + | socket.close(); | |
| 76 | + | return; | |
| 77 | + | } | |
| 78 | + | socket.send(JSON.stringify({ ...event, schemaVersion: VevTerminal.schemaVersion })); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | function fit() { | |
| 82 | + | if (fitting || !received) return; | |
| 83 | + | fitting = requestAnimationFrame(() => { | |
| 84 | + | fitting = 0; | |
| 85 | + | const cell = root.querySelector('.vev-terminal__cell'); | |
| 86 | + | const row = root.querySelector('.vev-terminal__row'); | |
| 87 | + | if (!cell || !row) return; | |
| 88 | + | const style = getComputedStyle(row); | |
| 89 | + | const cellWidth = parseFloat(style.gridTemplateColumns); | |
| 90 | + | const cellHeight = row.getBoundingClientRect().height; | |
| 91 | + | if (!(cellWidth > 0 && cellHeight > 0)) return; | |
| 92 | + | const columns = Math.max(2, Math.min(512, Math.floor((workspace.clientWidth - 8) / cellWidth))); | |
| 93 | + | const rows = Math.max(2, Math.min(256, Math.floor((workspace.clientHeight - 8) / cellHeight))); | |
| 94 | + | const event = { type: 'resize', columns, rows, pixelWidth: columns * cellWidth, pixelHeight: rows * cellHeight, cellWidth, cellHeight, devicePixelRatio: window.devicePixelRatio }; | |
| 95 | + | const identity = JSON.stringify(event); | |
| 96 | + | if (identity !== lastGeometry) { lastGeometry = identity; send(event); } | |
| 97 | + | }); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | function connect() { | |
| 101 | + | if (socket && socket.readyState < WebSocket.CLOSING) return; | |
| 102 | + | if (terminal) terminal.destroy(); | |
| 103 | + | received = false; | |
| 104 | + | mouse = false; | |
| 105 | + | lastGeometry = ''; | |
| 106 | + | terminal = VevTerminal.mount(root, { | |
| 107 | + | label: 'vev terminal multiplexer', | |
| 108 | + | decide(event) { | |
| 109 | + | if (event.type === 'resize' || event.type === 'focus') return { emit: false, preventDefault: false }; | |
| 110 | + | if (event.type === 'pointer') { | |
| 111 | + | const capture = mouse && !selecting && !event.shift; | |
| 112 | + | return { emit: capture, preventDefault: capture }; | |
| 113 | + | } | |
| 114 | + | if (event.type === 'wheel') { | |
| 115 | + | // Shift+wheel is fast scroll (x10 server-side); Shift+pointer stays | |
| 116 | + | // reserved for native selection. | |
| 117 | + | const capture = mouse && !selecting; | |
| 118 | + | return { emit: capture, preventDefault: capture }; | |
| 119 | + | } | |
| 120 | + | // Ctrl+Shift+F6 releases terminal focus for keyboard navigation. | |
| 121 | + | if (event.type === 'key' && event.key === 'F6' && event.ctrl && event.shift) { | |
| 122 | + | document.querySelector('#palette').focus(); | |
| 123 | + | return { emit: false, preventDefault: true }; | |
| 124 | + | } | |
| 125 | + | // Preserve native copy/paste and browser/OS shortcuts. | |
| 126 | + | if (event.type === 'key' && (event.meta || (event.ctrl && event.shift && ['C', 'V', 'c', 'v'].includes(event.key)) || (event.ctrl && !event.alt && event.key.toLowerCase() === 'v'))) { | |
| 127 | + | return { emit: false, preventDefault: false }; | |
| 128 | + | } | |
| 129 | + | return { emit: true, preventDefault: true }; | |
| 130 | + | }, | |
| 131 | + | send | |
| 132 | + | }); | |
| 133 | + | socket = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`); | |
| 134 | + | status.textContent = 'Connecting…'; | |
| 135 | + | reconnect.hidden = true; | |
| 136 | + | // Presentation coalescing: at most one scheduled present task per | |
| 137 | + | // animation frame, preserving every incremental update in order. Each | |
| 138 | + | // queued delta is still applied in order; never drops intermediate | |
| 139 | + | // deltas. requestAnimationFrame is throttled to zero in hidden pages, | |
| 140 | + | // so a timeout fallback keeps the terminal live there. | |
| 141 | + | let queuedUpdates = []; | |
| 142 | + | let presentScheduled = false; | |
| 143 | + | function flushUpdates() { | |
| 144 | + | presentScheduled = false; | |
| 145 | + | const batch = queuedUpdates; | |
| 146 | + | queuedUpdates = []; | |
| 147 | + | try { | |
| 148 | + | for (const update of batch) terminal.apply(update); | |
| 149 | + | } catch { | |
| 150 | + | status.textContent = 'Invalid terminal update — connection stopped.'; | |
| 151 | + | socket.close(); | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | function scheduleFlush() { | |
| 155 | + | if (presentScheduled) return; | |
| 156 | + | presentScheduled = true; | |
| 157 | + | if (document.visibilityState === 'visible') requestAnimationFrame(flushUpdates); | |
| 158 | + | else setTimeout(flushUpdates, 0); | |
| 159 | + | } | |
| 160 | + | socket.addEventListener('message', event => { | |
| 161 | + | try { | |
| 162 | + | const message = JSON.parse(event.data); | |
| 163 | + | if (!message || typeof message.update !== 'object' || message.update === null) throw new Error('bad update'); | |
| 164 | + | queuedUpdates.push(message.update); | |
| 165 | + | scheduleFlush(); | |
| 166 | + | if (mouse !== message.mouse) { | |
| 167 | + | mouse = message.mouse; | |
| 168 | + | terminal.setMouseCapture(mouse && !selecting); | |
| 169 | + | } | |
| 170 | + | if (!received) { received = true; if (!coarsePointer.matches) terminal.focus(); fit(); refreshViews(); } | |
| 171 | + | if (status.textContent !== 'Connected') status.textContent = 'Connected'; | |
| 172 | + | } catch { | |
| 173 | + | status.textContent = 'Invalid terminal update — connection stopped.'; | |
| 174 | + | socket.close(); | |
| 175 | + | } | |
| 176 | + | }); | |
| 177 | + | socket.addEventListener('close', () => { | |
| 178 | + | if (status.textContent === 'Connected' || status.textContent === 'Connecting…') { | |
| 179 | + | status.textContent = 'Disconnected — session shells remain in the daemon.'; | |
| 180 | + | } | |
| 181 | + | setViewCount(); | |
| 182 | + | reconnect.hidden = false; | |
| 183 | + | }); | |
| 184 | + | socket.addEventListener('error', () => { status.textContent = 'Connection failed.'; }); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | root.addEventListener('pointerdown', event => { if (!event.shiftKey && !selecting && terminal) terminal.focus(); }); | |
| 188 | + | document.querySelector('#palette').addEventListener('click', () => { | |
| 189 | + | if (!terminal) return; | |
| 190 | + | send({ type: 'key', key: ' ', code: 'Space', alt: true, ctrl: false, meta: false, shift: false, repeat: false, location: 0 }); | |
| 191 | + | terminal.focus(); | |
| 192 | + | }); | |
| 193 | + | reconnect.addEventListener('click', start); | |
| 194 | + | new ResizeObserver(fit).observe(workspace); | |
| 195 | + | document.fonts.ready.then(fit); | |
| 196 | + | window.addEventListener('pagehide', () => { if (socket) socket.close(); }); | |
| 197 | + | ||
| 198 | + | async function start() { | |
| 199 | + | const token = new URLSearchParams(location.hash.slice(1)).get('token'); | |
| 200 | + | try { | |
| 201 | + | if (token) { | |
| 202 | + | const login = await fetch('/login', { method: 'POST', body: new URLSearchParams({ token }), credentials: 'same-origin' }); | |
| 203 | + | if (!login.ok) throw new Error('login'); | |
| 204 | + | } | |
| 205 | + | const health = await fetch('/health', { credentials: 'same-origin' }); | |
| 206 | + | if (!health.ok) throw new Error('login'); | |
| 207 | + | history.replaceState(null, '', '/'); | |
| 208 | + | document.querySelector('#help').hidden = true; | |
| 209 | + | connect(); | |
| 210 | + | } catch (error) { | |
| 211 | + | if (error?.message === 'login') { | |
| 212 | + | status.textContent = 'Authentication required'; | |
| 213 | + | reconnect.hidden = true; | |
| 214 | + | document.querySelector('#help').hidden = false; | |
| 215 | + | return; | |
| 216 | + | } | |
| 217 | + | status.textContent = 'Disconnected — gateway unreachable. Reconnect to retry.'; | |
| 218 | + | reconnect.hidden = false; | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | window.addEventListener('hashchange', () => { | |
| 222 | + | if (new URLSearchParams(location.hash.slice(1)).has('token')) start(); | |
| 223 | + | }); | |
| 224 | + | start(); | |
| 225 | + | })(); | |
| @@ -0,0 +1,225 @@ | |||||
| 1 | + | (() => { | |||
| 2 | + | 'use strict'; | |||
| 3 | + | const root = document.querySelector('#terminal'); | |||
| 4 | + | const workspace = document.querySelector('#workspace'); | |||
| 5 | + | const status = document.querySelector('#status'); | |||
| 6 | + | const reconnect = document.querySelector('#reconnect'); | |||
| 7 | + | let socket; | |||
| 8 | + | let terminal; | |||
| 9 | + | let mouse = false; | |||
| 10 | + | let lastGeometry = ''; | |||
| 11 | + | let fitting = 0; | |||
| 12 | + | let received = false; | |||
| 13 | + | const coarsePointer = matchMedia('(pointer: coarse)'); | |||
| 14 | + | let selecting = coarsePointer.matches; | |||
| 15 | + | const selectButton = document.querySelector('#select-mode'); | |||
| 16 | + | function setSelecting(value) { | |||
| 17 | + | selecting = value; | |||
| 18 | + | selectButton.setAttribute('aria-pressed', String(value)); | |||
| 19 | + | if (terminal) terminal.setMouseCapture(mouse && !selecting); | |||
| 20 | + | if (value) root.querySelector('textarea')?.blur(); | |||
| 21 | + | } | |||
| 22 | + | setSelecting(selecting); | |||
| 23 | + | selectButton.addEventListener('click', () => setSelecting(!selecting)); | |||
| 24 | + | document.querySelector('#keyboard').addEventListener('click', () => { | |||
| 25 | + | setSelecting(false); | |||
| 26 | + | terminal?.focus(); | |||
| 27 | + | }); | |||
| 28 | + | function fitVisualViewport() { | |||
| 29 | + | const viewport = window.visualViewport; | |||
| 30 | + | // Pinch zoom must not resize the PTY or fight the browser's pan gesture. | |||
| 31 | + | if (!viewport || Math.abs(viewport.scale - 1) > 0.01) return; | |||
| 32 | + | document.body.style.setProperty('--app-height', `${viewport.height}px`); | |||
| 33 | + | window.scrollTo(0, 0); | |||
| 34 | + | fit(); | |||
| 35 | + | } | |||
| 36 | + | window.visualViewport?.addEventListener('resize', fitVisualViewport); | |||
| 37 | + | fitVisualViewport(); | |||
| 38 | + | ||||
| 39 | + | function syncStatus() { | |||
| 40 | + | const text = status.textContent; | |||
| 41 | + | const indicator = document.querySelector('#connection'); | |||
| 42 | + | indicator.title = text; | |||
| 43 | + | indicator.dataset.state = text === 'Connected' ? 'connected' : text === 'Connecting…' ? 'connecting' : text.startsWith('Disconnected') ? 'disconnected' : 'error'; | |||
| 44 | + | } | |||
| 45 | + | new MutationObserver(syncStatus).observe(status, { childList: true, characterData: true, subtree: true }); | |||
| 46 | + | function setViewCount(count = null) { | |||
| 47 | + | document.querySelector('#view-count').textContent = count === null ? '—' : String(count); | |||
| 48 | + | document.querySelector('#view-description').textContent = `Connected browser terminal views: ${count === null ? 'unknown' : count}`; | |||
| 49 | + | } | |||
| 50 | + | let counting = false; | |||
| 51 | + | async function refreshViews() { | |||
| 52 | + | if (counting) return; | |||
| 53 | + | if (!socket || socket.readyState !== WebSocket.OPEN) { | |||
| 54 | + | setViewCount(); | |||
| 55 | + | return; | |||
| 56 | + | } | |||
| 57 | + | counting = true; | |||
| 58 | + | const current = socket; | |||
| 59 | + | try { | |||
| 60 | + | const response = await fetch('/views', { signal: AbortSignal.timeout(3000) }); | |||
| 61 | + | if (!response.ok) throw new Error('unavailable'); | |||
| 62 | + | const { active } = await response.json(); | |||
| 63 | + | if (!Number.isInteger(active) || active < 0) throw new Error('invalid count'); | |||
| 64 | + | if (socket === current && current.readyState === WebSocket.OPEN) setViewCount(active); | |||
| 65 | + | } catch { | |||
| 66 | + | if (socket === current) setViewCount(); | |||
| 67 | + | } finally { counting = false; } | |||
| 68 | + | } | |||
| 69 | + | setInterval(refreshViews, 2000); | |||
| 70 | + | ||||
| 71 | + | function send(event) { | |||
| 72 | + | if (!socket || socket.readyState !== WebSocket.OPEN) return; | |||
| 73 | + | if (socket.bufferedAmount > 1 << 20) { | |||
| 74 | + | status.textContent = 'Connection too slow — input stopped. Reconnect to continue.'; | |||
| 75 | + | socket.close(); | |||
| 76 | + | return; | |||
| 77 | + | } | |||
| 78 | + | socket.send(JSON.stringify({ ...event, schemaVersion: VevTerminal.schemaVersion })); | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | function fit() { | |||
| 82 | + | if (fitting || !received) return; | |||
| 83 | + | fitting = requestAnimationFrame(() => { | |||
| 84 | + | fitting = 0; | |||
| 85 | + | const cell = root.querySelector('.vev-terminal__cell'); | |||
| 86 | + | const row = root.querySelector('.vev-terminal__row'); | |||
| 87 | + | if (!cell || !row) return; | |||
| 88 | + | const style = getComputedStyle(row); | |||
| 89 | + | const cellWidth = parseFloat(style.gridTemplateColumns); | |||
| 90 | + | const cellHeight = row.getBoundingClientRect().height; | |||
| 91 | + | if (!(cellWidth > 0 && cellHeight > 0)) return; | |||
| 92 | + | const columns = Math.max(2, Math.min(512, Math.floor((workspace.clientWidth - 8) / cellWidth))); | |||
| 93 | + | const rows = Math.max(2, Math.min(256, Math.floor((workspace.clientHeight - 8) / cellHeight))); | |||
| 94 | + | const event = { type: 'resize', columns, rows, pixelWidth: columns * cellWidth, pixelHeight: rows * cellHeight, cellWidth, cellHeight, devicePixelRatio: window.devicePixelRatio }; | |||
| 95 | + | const identity = JSON.stringify(event); | |||
| 96 | + | if (identity !== lastGeometry) { lastGeometry = identity; send(event); } | |||
| 97 | + | }); | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | function connect() { | |||
| 101 | + | if (socket && socket.readyState < WebSocket.CLOSING) return; | |||
| 102 | + | if (terminal) terminal.destroy(); | |||
| 103 | + | received = false; | |||
| 104 | + | mouse = false; | |||
| 105 | + | lastGeometry = ''; | |||
| 106 | + | terminal = VevTerminal.mount(root, { | |||
| 107 | + | label: 'vev terminal multiplexer', | |||
| 108 | + | decide(event) { | |||
| 109 | + | if (event.type === 'resize' || event.type === 'focus') return { emit: false, preventDefault: false }; | |||
| 110 | + | if (event.type === 'pointer') { | |||
| 111 | + | const capture = mouse && !selecting && !event.shift; | |||
| 112 | + | return { emit: capture, preventDefault: capture }; | |||
| 113 | + | } | |||
| 114 | + | if (event.type === 'wheel') { | |||
| 115 | + | // Shift+wheel is fast scroll (x10 server-side); Shift+pointer stays | |||
| 116 | + | // reserved for native selection. | |||
| 117 | + | const capture = mouse && !selecting; | |||
| 118 | + | return { emit: capture, preventDefault: capture }; | |||
| 119 | + | } | |||
| 120 | + | // Ctrl+Shift+F6 releases terminal focus for keyboard navigation. | |||
| 121 | + | if (event.type === 'key' && event.key === 'F6' && event.ctrl && event.shift) { | |||
| 122 | + | document.querySelector('#palette').focus(); | |||
| 123 | + | return { emit: false, preventDefault: true }; | |||
| 124 | + | } | |||
| 125 | + | // Preserve native copy/paste and browser/OS shortcuts. | |||
| 126 | + | if (event.type === 'key' && (event.meta || (event.ctrl && event.shift && ['C', 'V', 'c', 'v'].includes(event.key)) || (event.ctrl && !event.alt && event.key.toLowerCase() === 'v'))) { | |||
| 127 | + | return { emit: false, preventDefault: false }; | |||
| 128 | + | } | |||
| 129 | + | return { emit: true, preventDefault: true }; | |||
| 130 | + | }, | |||
| 131 | + | send | |||
| 132 | + | }); | |||
| 133 | + | socket = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`); | |||
| 134 | + | status.textContent = 'Connecting…'; | |||
| 135 | + | reconnect.hidden = true; | |||
| 136 | + | // Presentation coalescing: at most one scheduled present task per | |||
| 137 | + | // animation frame, preserving every incremental update in order. Each | |||
| 138 | + | // queued delta is still applied in order; never drops intermediate | |||
| 139 | + | // deltas. requestAnimationFrame is throttled to zero in hidden pages, | |||
| 140 | + | // so a timeout fallback keeps the terminal live there. | |||
| 141 | + | let queuedUpdates = []; | |||
| 142 | + | let presentScheduled = false; | |||
| 143 | + | function flushUpdates() { | |||
| 144 | + | presentScheduled = false; | |||
| 145 | + | const batch = queuedUpdates; | |||
| 146 | + | queuedUpdates = []; | |||
| 147 | + | try { | |||
| 148 | + | for (const update of batch) terminal.apply(update); | |||
| 149 | + | } catch { | |||
| 150 | + | status.textContent = 'Invalid terminal update — connection stopped.'; | |||
| 151 | + | socket.close(); | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | function scheduleFlush() { | |||
| 155 | + | if (presentScheduled) return; | |||
| 156 | + | presentScheduled = true; | |||
| 157 | + | if (document.visibilityState === 'visible') requestAnimationFrame(flushUpdates); | |||
| 158 | + | else setTimeout(flushUpdates, 0); | |||
| 159 | + | } | |||
| 160 | + | socket.addEventListener('message', event => { | |||
| 161 | + | try { | |||
| 162 | + | const message = JSON.parse(event.data); | |||
| 163 | + | if (!message || typeof message.update !== 'object' || message.update === null) throw new Error('bad update'); | |||
| 164 | + | queuedUpdates.push(message.update); | |||
| 165 | + | scheduleFlush(); | |||
| 166 | + | if (mouse !== message.mouse) { | |||
| 167 | + | mouse = message.mouse; | |||
| 168 | + | terminal.setMouseCapture(mouse && !selecting); | |||
| 169 | + | } | |||
| 170 | + | if (!received) { received = true; if (!coarsePointer.matches) terminal.focus(); fit(); refreshViews(); } | |||
| 171 | + | if (status.textContent !== 'Connected') status.textContent = 'Connected'; | |||
| 172 | + | } catch { | |||
| 173 | + | status.textContent = 'Invalid terminal update — connection stopped.'; | |||
| 174 | + | socket.close(); | |||
| 175 | + | } | |||
| 176 | + | }); | |||
| 177 | + | socket.addEventListener('close', () => { | |||
| 178 | + | if (status.textContent === 'Connected' || status.textContent === 'Connecting…') { | |||
| 179 | + | status.textContent = 'Disconnected — session shells remain in the daemon.'; | |||
| 180 | + | } | |||
| 181 | + | setViewCount(); | |||
| 182 | + | reconnect.hidden = false; | |||
| 183 | + | }); | |||
| 184 | + | socket.addEventListener('error', () => { status.textContent = 'Connection failed.'; }); | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | root.addEventListener('pointerdown', event => { if (!event.shiftKey && !selecting && terminal) terminal.focus(); }); | |||
| 188 | + | document.querySelector('#palette').addEventListener('click', () => { | |||
| 189 | + | if (!terminal) return; | |||
| 190 | + | send({ type: 'key', key: ' ', code: 'Space', alt: true, ctrl: false, meta: false, shift: false, repeat: false, location: 0 }); | |||
| 191 | + | terminal.focus(); | |||
| 192 | + | }); | |||
| 193 | + | reconnect.addEventListener('click', start); | |||
| 194 | + | new ResizeObserver(fit).observe(workspace); | |||
| 195 | + | document.fonts.ready.then(fit); | |||
| 196 | + | window.addEventListener('pagehide', () => { if (socket) socket.close(); }); | |||
| 197 | + | ||||
| 198 | + | async function start() { | |||
| 199 | + | const token = new URLSearchParams(location.hash.slice(1)).get('token'); | |||
| 200 | + | try { | |||
| 201 | + | if (token) { | |||
| 202 | + | const login = await fetch('/login', { method: 'POST', body: new URLSearchParams({ token }), credentials: 'same-origin' }); | |||
| 203 | + | if (!login.ok) throw new Error('login'); | |||
| 204 | + | } | |||
| 205 | + | const health = await fetch('/health', { credentials: 'same-origin' }); | |||
| 206 | + | if (!health.ok) throw new Error('login'); | |||
| 207 | + | history.replaceState(null, '', '/'); | |||
| 208 | + | document.querySelector('#help').hidden = true; | |||
| 209 | + | connect(); | |||
| 210 | + | } catch (error) { | |||
| 211 | + | if (error?.message === 'login') { | |||
| 212 | + | status.textContent = 'Authentication required'; | |||
| 213 | + | reconnect.hidden = true; | |||
| 214 | + | document.querySelector('#help').hidden = false; | |||
| 215 | + | return; | |||
| 216 | + | } | |||
| 217 | + | status.textContent = 'Disconnected — gateway unreachable. Reconnect to retry.'; | |||
| 218 | + | reconnect.hidden = false; | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | window.addEventListener('hashchange', () => { | |||
| 222 | + | if (new URLSearchParams(location.hash.slice(1)).has('token')) start(); | |||
| 223 | + | }); | |||
| 224 | + | start(); | |||
| 225 | + | })(); | |||
A internal/adapters/webterm/assets.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import _ "embed" | |
| 4 | + | ||
| 5 | + | //go:embed index.html | |
| 6 | + | var indexHTML string | |
| 7 | + | ||
| 8 | + | //go:embed app.js | |
| 9 | + | var appJS string | |
| 10 | + | ||
| 11 | + | //go:embed app.css | |
| 12 | + | var appCSS string | |
| 13 | + | ||
| 14 | + | //go:embed ICONS-LICENSE | |
| 15 | + | var iconsLicense string | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import _ "embed" | |||
| 4 | + | ||||
| 5 | + | //go:embed index.html | |||
| 6 | + | var indexHTML string | |||
| 7 | + | ||||
| 8 | + | //go:embed app.js | |||
| 9 | + | var appJS string | |||
| 10 | + | ||||
| 11 | + | //go:embed app.css | |||
| 12 | + | var appCSS string | |||
| 13 | + | ||||
| 14 | + | //go:embed ICONS-LICENSE | |||
| 15 | + | var iconsLicense string | |||
A internal/adapters/webterm/index.html +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"> | |
| 6 | + | <title>vev · terminal</title> | |
| 7 | + | <link rel="stylesheet" href="/terminal.css"> | |
| 8 | + | <script defer src="/terminal.js"></script> | |
| 9 | + | <script defer src="/app.js"></script> | |
| 10 | + | </head> | |
| 11 | + | <body> | |
| 12 | + | <header> | |
| 13 | + | <strong>vev</strong> | |
| 14 | + | <span id="connection" data-state="connecting" title="Connecting…"><i aria-hidden="true"></i><span id="status" class="sr-only" role="status">Connecting…</span></span> | |
| 15 | + | <span id="views" title="Connected browser terminal views"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></svg><span id="view-count" aria-hidden="true">—</span><span id="view-description" class="sr-only">Connected browser terminal views: unknown</span></span> | |
| 16 | + | <span class="header-space"></span> | |
| 17 | + | <button id="select-mode" class="touch-control" type="button" aria-pressed="false" title="Select text without sending terminal input">Select</button> | |
| 18 | + | <button id="keyboard" class="touch-control" type="button" title="Open terminal keyboard">Keys</button> | |
| 19 | + | <button id="palette" type="button" title="Command palette · Alt+Space" aria-label="Command palette"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m7 11 2-2-2-2M11 13h4"/><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/></svg></button> | |
| 20 | + | <button id="reconnect" type="button" title="Reconnect" aria-label="Reconnect" hidden><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8M21 3v5h-5"/></svg></button> | |
| 21 | + | </header> | |
| 22 | + | <main id="workspace"><div id="terminal"></div></main> | |
| 23 | + | <p id="help" hidden>Open the private link printed by <code>vev --web-daemon</code> to connect. Browser-reserved shortcuts remain owned by the browser.</p> | |
| 24 | + | <noscript>This terminal requires its small native JavaScript adapter. No frontend framework is used.</noscript> | |
| 25 | + | </body> | |
| 26 | + | </html> | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"> | |||
| 6 | + | <title>vev · terminal</title> | |||
| 7 | + | <link rel="stylesheet" href="/terminal.css"> | |||
| 8 | + | <script defer src="/terminal.js"></script> | |||
| 9 | + | <script defer src="/app.js"></script> | |||
| 10 | + | </head> | |||
| 11 | + | <body> | |||
| 12 | + | <header> | |||
| 13 | + | <strong>vev</strong> | |||
| 14 | + | <span id="connection" data-state="connecting" title="Connecting…"><i aria-hidden="true"></i><span id="status" class="sr-only" role="status">Connecting…</span></span> | |||
| 15 | + | <span id="views" title="Connected browser terminal views"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></svg><span id="view-count" aria-hidden="true">—</span><span id="view-description" class="sr-only">Connected browser terminal views: unknown</span></span> | |||
| 16 | + | <span class="header-space"></span> | |||
| 17 | + | <button id="select-mode" class="touch-control" type="button" aria-pressed="false" title="Select text without sending terminal input">Select</button> | |||
| 18 | + | <button id="keyboard" class="touch-control" type="button" title="Open terminal keyboard">Keys</button> | |||
| 19 | + | <button id="palette" type="button" title="Command palette · Alt+Space" aria-label="Command palette"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m7 11 2-2-2-2M11 13h4"/><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/></svg></button> | |||
| 20 | + | <button id="reconnect" type="button" title="Reconnect" aria-label="Reconnect" hidden><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8M21 3v5h-5"/></svg></button> | |||
| 21 | + | </header> | |||
| 22 | + | <main id="workspace"><div id="terminal"></div></main> | |||
| 23 | + | <p id="help" hidden>Open the private link printed by <code>vev --web-daemon</code> to connect. Browser-reserved shortcuts remain owned by the browser.</p> | |||
| 24 | + | <noscript>This terminal requires its small native JavaScript adapter. No frontend framework is used.</noscript> | |||
| 25 | + | </body> | |||
| 26 | + | </html> | |||
A internal/adapters/webterm/input.go +239 -0
| @@ -0,0 +1,239 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "unicode/utf8" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev-vt/html/browser" | |
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Handle translates validated browser events into ordinary terminal input. | |
| 14 | + | // Paste never admits embedded terminal framing or C0 commands other than text | |
| 15 | + | // whitespace. The client retains its normal bracketed-paste policy. | |
| 16 | + | func (t *Terminal) Handle(ctx context.Context, event browser.Event) error { | |
| 17 | + | snapshot := t.Snapshot() | |
| 18 | + | modes := snapshot.Modes() | |
| 19 | + | var data string | |
| 20 | + | switch event.Kind { | |
| 21 | + | case browser.EventResize: | |
| 22 | + | e := event.Resize | |
| 23 | + | return t.Resize(domain.Geometry{Size: domain.Size{Cols: e.Columns, Rows: e.Rows}, PixelWidth: int(e.PixelWidth), PixelHeight: int(e.PixelHeight)}) | |
| 24 | + | case browser.EventText: | |
| 25 | + | data = cleanText(event.Text.Text, false) | |
| 26 | + | case browser.EventPaste: | |
| 27 | + | data = cleanText(event.Paste.Text, true) | |
| 28 | + | if modes.BracketedPaste { | |
| 29 | + | data = "\x1b[200~" + data + "\x1b[201~" | |
| 30 | + | } | |
| 31 | + | case browser.EventKey: | |
| 32 | + | data = encodeKey(*event.Key, snapshot.ApplicationCursorMode()) | |
| 33 | + | case browser.EventPointer: | |
| 34 | + | e := event.Pointer | |
| 35 | + | if modes.MouseTracking == 0 || !modes.MouseSGR || e.Row < 0 || e.Column < 0 || e.Row >= snapshot.Rows() || e.Column >= snapshot.Columns() { | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | button := e.Button | |
| 39 | + | final := "M" | |
| 40 | + | switch e.Action { | |
| 41 | + | case "down": | |
| 42 | + | if button < 0 || button > 2 { | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | case "up": | |
| 46 | + | if button < 0 || button > 2 { | |
| 47 | + | return nil | |
| 48 | + | } | |
| 49 | + | final = "m" | |
| 50 | + | case "move": | |
| 51 | + | if modes.MouseTracking != 1003 && (modes.MouseTracking != 1002 || e.Buttons == 0) { | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | button = 3 | |
| 55 | + | if e.Buttons&1 != 0 { | |
| 56 | + | button = 0 | |
| 57 | + | } else if e.Buttons&4 != 0 { | |
| 58 | + | button = 1 | |
| 59 | + | } else if e.Buttons&2 != 0 { | |
| 60 | + | button = 2 | |
| 61 | + | } | |
| 62 | + | button += 32 | |
| 63 | + | default: | |
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | data = fmt.Sprintf("\x1b[<%d;%d;%d%s", button+mouseModifiers(e.Modifiers), e.Column+1, e.Row+1, final) | |
| 67 | + | case browser.EventWheel: | |
| 68 | + | e := event.Wheel | |
| 69 | + | if modes.MouseTracking == 0 || !modes.MouseSGR || e.Row < 0 || e.Column < 0 || e.Row >= snapshot.Rows() || e.Column >= snapshot.Columns() { | |
| 70 | + | t.resetWheel() | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | if e.DeltaY == 0 { | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | reports, button := t.consumeWheel(e.DeltaY, e.DeltaMode, e.Modifiers.Shift) | |
| 77 | + | if reports == 0 { | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | // Shift is consumed as the x10 fast-scroll multiplier, so it must | |
| 81 | + | // not also leak into the SGR modifiers (buttons 68/69), which | |
| 82 | + | // applications would not recognize as wheel reports. | |
| 83 | + | modifiers := e.Modifiers | |
| 84 | + | if e.Modifiers.Shift { | |
| 85 | + | modifiers.Shift = false | |
| 86 | + | } | |
| 87 | + | var sb strings.Builder | |
| 88 | + | for i := 0; i < reports; i++ { | |
| 89 | + | fmt.Fprintf(&sb, "\x1b[<%d;%d;%dM", button+mouseModifiers(modifiers), e.Column+1, e.Row+1) | |
| 90 | + | } | |
| 91 | + | data = sb.String() | |
| 92 | + | case browser.EventFocus: | |
| 93 | + | // Focus reporting is not negotiated by the current client terminal contract. | |
| 94 | + | return nil | |
| 95 | + | } | |
| 96 | + | return t.Send(ctx, []byte(data)) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func mouseModifiers(m browser.Modifiers) int { | |
| 100 | + | n := 0 | |
| 101 | + | if m.Shift { | |
| 102 | + | n += 4 | |
| 103 | + | } | |
| 104 | + | if m.Alt { | |
| 105 | + | n += 8 | |
| 106 | + | } | |
| 107 | + | if m.Ctrl { | |
| 108 | + | n += 16 | |
| 109 | + | } | |
| 110 | + | return n | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func cleanText(text string, whitespace bool) string { | |
| 114 | + | return strings.Map(func(r rune) rune { | |
| 115 | + | if whitespace && (r == '\n' || r == '\r' || r == '\t') { | |
| 116 | + | return r | |
| 117 | + | } | |
| 118 | + | if r < 32 || r >= 127 && r <= 159 { | |
| 119 | + | return -1 | |
| 120 | + | } | |
| 121 | + | return r | |
| 122 | + | }, text) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func encodeKey(e browser.KeyEvent, applicationCursor bool) string { | |
| 126 | + | if e.Modifiers.Meta { | |
| 127 | + | return "" | |
| 128 | + | } | |
| 129 | + | modifier := 1 | |
| 130 | + | if e.Modifiers.Shift { | |
| 131 | + | modifier += 1 | |
| 132 | + | } | |
| 133 | + | if e.Modifiers.Alt { | |
| 134 | + | modifier += 2 | |
| 135 | + | } | |
| 136 | + | if e.Modifiers.Ctrl { | |
| 137 | + | modifier += 4 | |
| 138 | + | } | |
| 139 | + | var final string | |
| 140 | + | switch e.Key { | |
| 141 | + | case "ArrowUp": | |
| 142 | + | final = "A" | |
| 143 | + | case "ArrowDown": | |
| 144 | + | final = "B" | |
| 145 | + | case "ArrowRight": | |
| 146 | + | final = "C" | |
| 147 | + | case "ArrowLeft": | |
| 148 | + | final = "D" | |
| 149 | + | case "Home": | |
| 150 | + | final = "H" | |
| 151 | + | case "End": | |
| 152 | + | final = "F" | |
| 153 | + | } | |
| 154 | + | if final != "" { | |
| 155 | + | if modifier > 1 { | |
| 156 | + | return fmt.Sprintf("\x1b[1;%d%s", modifier, final) | |
| 157 | + | } | |
| 158 | + | if applicationCursor { | |
| 159 | + | return "\x1bO" + final | |
| 160 | + | } | |
| 161 | + | return "\x1b[" + final | |
| 162 | + | } | |
| 163 | + | number := 0 | |
| 164 | + | switch e.Key { | |
| 165 | + | case "Insert": | |
| 166 | + | number = 2 | |
| 167 | + | case "Delete": | |
| 168 | + | number = 3 | |
| 169 | + | case "PageUp": | |
| 170 | + | number = 5 | |
| 171 | + | case "PageDown": | |
| 172 | + | number = 6 | |
| 173 | + | case "F5": | |
| 174 | + | number = 15 | |
| 175 | + | case "F6": | |
| 176 | + | number = 17 | |
| 177 | + | case "F7": | |
| 178 | + | number = 18 | |
| 179 | + | case "F8": | |
| 180 | + | number = 19 | |
| 181 | + | case "F9": | |
| 182 | + | number = 20 | |
| 183 | + | case "F10": | |
| 184 | + | number = 21 | |
| 185 | + | case "F11": | |
| 186 | + | number = 23 | |
| 187 | + | case "F12": | |
| 188 | + | number = 24 | |
| 189 | + | } | |
| 190 | + | if number != 0 { | |
| 191 | + | if modifier > 1 { | |
| 192 | + | return fmt.Sprintf("\x1b[%d;%d~", number, modifier) | |
| 193 | + | } | |
| 194 | + | return fmt.Sprintf("\x1b[%d~", number) | |
| 195 | + | } | |
| 196 | + | if len(e.Key) == 2 && e.Key[0] == 'F' && e.Key[1] >= '1' && e.Key[1] <= '4' { | |
| 197 | + | final := string(rune('P' + e.Key[1] - '1')) | |
| 198 | + | if modifier > 1 { | |
| 199 | + | return fmt.Sprintf("\x1b[1;%d%s", modifier, final) | |
| 200 | + | } | |
| 201 | + | return "\x1bO" + final | |
| 202 | + | } | |
| 203 | + | var key string | |
| 204 | + | switch e.Key { | |
| 205 | + | case "Enter": | |
| 206 | + | key = "\r" | |
| 207 | + | case "Escape": | |
| 208 | + | key = "\x1b" | |
| 209 | + | case "Backspace": | |
| 210 | + | key = "\x7f" | |
| 211 | + | case "Tab": | |
| 212 | + | if e.Modifiers.Shift { | |
| 213 | + | if e.Modifiers.Alt { | |
| 214 | + | return "\x1b\x1b[Z" | |
| 215 | + | } | |
| 216 | + | return "\x1b[Z" | |
| 217 | + | } | |
| 218 | + | key = "\t" | |
| 219 | + | default: | |
| 220 | + | if utf8.RuneCountInString(e.Key) != 1 { | |
| 221 | + | return "" | |
| 222 | + | } | |
| 223 | + | key = cleanText(e.Key, false) | |
| 224 | + | if e.Modifiers.Ctrl { | |
| 225 | + | upper := strings.ToUpper(key) | |
| 226 | + | if upper == " " || upper == "@" { | |
| 227 | + | key = "\x00" | |
| 228 | + | } else if len(upper) == 1 && upper[0] >= 'A' && upper[0] <= '_' { | |
| 229 | + | key = string([]byte{upper[0] & 31}) | |
| 230 | + | } else { | |
| 231 | + | return "" | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | if e.Modifiers.Alt { | |
| 236 | + | key = "\x1b" + key | |
| 237 | + | } | |
| 238 | + | return key | |
| 239 | + | } | |
| @@ -0,0 +1,239 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "unicode/utf8" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev-vt/html/browser" | |||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Handle translates validated browser events into ordinary terminal input. | |||
| 14 | + | // Paste never admits embedded terminal framing or C0 commands other than text | |||
| 15 | + | // whitespace. The client retains its normal bracketed-paste policy. | |||
| 16 | + | func (t *Terminal) Handle(ctx context.Context, event browser.Event) error { | |||
| 17 | + | snapshot := t.Snapshot() | |||
| 18 | + | modes := snapshot.Modes() | |||
| 19 | + | var data string | |||
| 20 | + | switch event.Kind { | |||
| 21 | + | case browser.EventResize: | |||
| 22 | + | e := event.Resize | |||
| 23 | + | return t.Resize(domain.Geometry{Size: domain.Size{Cols: e.Columns, Rows: e.Rows}, PixelWidth: int(e.PixelWidth), PixelHeight: int(e.PixelHeight)}) | |||
| 24 | + | case browser.EventText: | |||
| 25 | + | data = cleanText(event.Text.Text, false) | |||
| 26 | + | case browser.EventPaste: | |||
| 27 | + | data = cleanText(event.Paste.Text, true) | |||
| 28 | + | if modes.BracketedPaste { | |||
| 29 | + | data = "\x1b[200~" + data + "\x1b[201~" | |||
| 30 | + | } | |||
| 31 | + | case browser.EventKey: | |||
| 32 | + | data = encodeKey(*event.Key, snapshot.ApplicationCursorMode()) | |||
| 33 | + | case browser.EventPointer: | |||
| 34 | + | e := event.Pointer | |||
| 35 | + | if modes.MouseTracking == 0 || !modes.MouseSGR || e.Row < 0 || e.Column < 0 || e.Row >= snapshot.Rows() || e.Column >= snapshot.Columns() { | |||
| 36 | + | return nil | |||
| 37 | + | } | |||
| 38 | + | button := e.Button | |||
| 39 | + | final := "M" | |||
| 40 | + | switch e.Action { | |||
| 41 | + | case "down": | |||
| 42 | + | if button < 0 || button > 2 { | |||
| 43 | + | return nil | |||
| 44 | + | } | |||
| 45 | + | case "up": | |||
| 46 | + | if button < 0 || button > 2 { | |||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | final = "m" | |||
| 50 | + | case "move": | |||
| 51 | + | if modes.MouseTracking != 1003 && (modes.MouseTracking != 1002 || e.Buttons == 0) { | |||
| 52 | + | return nil | |||
| 53 | + | } | |||
| 54 | + | button = 3 | |||
| 55 | + | if e.Buttons&1 != 0 { | |||
| 56 | + | button = 0 | |||
| 57 | + | } else if e.Buttons&4 != 0 { | |||
| 58 | + | button = 1 | |||
| 59 | + | } else if e.Buttons&2 != 0 { | |||
| 60 | + | button = 2 | |||
| 61 | + | } | |||
| 62 | + | button += 32 | |||
| 63 | + | default: | |||
| 64 | + | return nil | |||
| 65 | + | } | |||
| 66 | + | data = fmt.Sprintf("\x1b[<%d;%d;%d%s", button+mouseModifiers(e.Modifiers), e.Column+1, e.Row+1, final) | |||
| 67 | + | case browser.EventWheel: | |||
| 68 | + | e := event.Wheel | |||
| 69 | + | if modes.MouseTracking == 0 || !modes.MouseSGR || e.Row < 0 || e.Column < 0 || e.Row >= snapshot.Rows() || e.Column >= snapshot.Columns() { | |||
| 70 | + | t.resetWheel() | |||
| 71 | + | return nil | |||
| 72 | + | } | |||
| 73 | + | if e.DeltaY == 0 { | |||
| 74 | + | return nil | |||
| 75 | + | } | |||
| 76 | + | reports, button := t.consumeWheel(e.DeltaY, e.DeltaMode, e.Modifiers.Shift) | |||
| 77 | + | if reports == 0 { | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | // Shift is consumed as the x10 fast-scroll multiplier, so it must | |||
| 81 | + | // not also leak into the SGR modifiers (buttons 68/69), which | |||
| 82 | + | // applications would not recognize as wheel reports. | |||
| 83 | + | modifiers := e.Modifiers | |||
| 84 | + | if e.Modifiers.Shift { | |||
| 85 | + | modifiers.Shift = false | |||
| 86 | + | } | |||
| 87 | + | var sb strings.Builder | |||
| 88 | + | for i := 0; i < reports; i++ { | |||
| 89 | + | fmt.Fprintf(&sb, "\x1b[<%d;%d;%dM", button+mouseModifiers(modifiers), e.Column+1, e.Row+1) | |||
| 90 | + | } | |||
| 91 | + | data = sb.String() | |||
| 92 | + | case browser.EventFocus: | |||
| 93 | + | // Focus reporting is not negotiated by the current client terminal contract. | |||
| 94 | + | return nil | |||
| 95 | + | } | |||
| 96 | + | return t.Send(ctx, []byte(data)) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func mouseModifiers(m browser.Modifiers) int { | |||
| 100 | + | n := 0 | |||
| 101 | + | if m.Shift { | |||
| 102 | + | n += 4 | |||
| 103 | + | } | |||
| 104 | + | if m.Alt { | |||
| 105 | + | n += 8 | |||
| 106 | + | } | |||
| 107 | + | if m.Ctrl { | |||
| 108 | + | n += 16 | |||
| 109 | + | } | |||
| 110 | + | return n | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func cleanText(text string, whitespace bool) string { | |||
| 114 | + | return strings.Map(func(r rune) rune { | |||
| 115 | + | if whitespace && (r == '\n' || r == '\r' || r == '\t') { | |||
| 116 | + | return r | |||
| 117 | + | } | |||
| 118 | + | if r < 32 || r >= 127 && r <= 159 { | |||
| 119 | + | return -1 | |||
| 120 | + | } | |||
| 121 | + | return r | |||
| 122 | + | }, text) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func encodeKey(e browser.KeyEvent, applicationCursor bool) string { | |||
| 126 | + | if e.Modifiers.Meta { | |||
| 127 | + | return "" | |||
| 128 | + | } | |||
| 129 | + | modifier := 1 | |||
| 130 | + | if e.Modifiers.Shift { | |||
| 131 | + | modifier += 1 | |||
| 132 | + | } | |||
| 133 | + | if e.Modifiers.Alt { | |||
| 134 | + | modifier += 2 | |||
| 135 | + | } | |||
| 136 | + | if e.Modifiers.Ctrl { | |||
| 137 | + | modifier += 4 | |||
| 138 | + | } | |||
| 139 | + | var final string | |||
| 140 | + | switch e.Key { | |||
| 141 | + | case "ArrowUp": | |||
| 142 | + | final = "A" | |||
| 143 | + | case "ArrowDown": | |||
| 144 | + | final = "B" | |||
| 145 | + | case "ArrowRight": | |||
| 146 | + | final = "C" | |||
| 147 | + | case "ArrowLeft": | |||
| 148 | + | final = "D" | |||
| 149 | + | case "Home": | |||
| 150 | + | final = "H" | |||
| 151 | + | case "End": | |||
| 152 | + | final = "F" | |||
| 153 | + | } | |||
| 154 | + | if final != "" { | |||
| 155 | + | if modifier > 1 { | |||
| 156 | + | return fmt.Sprintf("\x1b[1;%d%s", modifier, final) | |||
| 157 | + | } | |||
| 158 | + | if applicationCursor { | |||
| 159 | + | return "\x1bO" + final | |||
| 160 | + | } | |||
| 161 | + | return "\x1b[" + final | |||
| 162 | + | } | |||
| 163 | + | number := 0 | |||
| 164 | + | switch e.Key { | |||
| 165 | + | case "Insert": | |||
| 166 | + | number = 2 | |||
| 167 | + | case "Delete": | |||
| 168 | + | number = 3 | |||
| 169 | + | case "PageUp": | |||
| 170 | + | number = 5 | |||
| 171 | + | case "PageDown": | |||
| 172 | + | number = 6 | |||
| 173 | + | case "F5": | |||
| 174 | + | number = 15 | |||
| 175 | + | case "F6": | |||
| 176 | + | number = 17 | |||
| 177 | + | case "F7": | |||
| 178 | + | number = 18 | |||
| 179 | + | case "F8": | |||
| 180 | + | number = 19 | |||
| 181 | + | case "F9": | |||
| 182 | + | number = 20 | |||
| 183 | + | case "F10": | |||
| 184 | + | number = 21 | |||
| 185 | + | case "F11": | |||
| 186 | + | number = 23 | |||
| 187 | + | case "F12": | |||
| 188 | + | number = 24 | |||
| 189 | + | } | |||
| 190 | + | if number != 0 { | |||
| 191 | + | if modifier > 1 { | |||
| 192 | + | return fmt.Sprintf("\x1b[%d;%d~", number, modifier) | |||
| 193 | + | } | |||
| 194 | + | return fmt.Sprintf("\x1b[%d~", number) | |||
| 195 | + | } | |||
| 196 | + | if len(e.Key) == 2 && e.Key[0] == 'F' && e.Key[1] >= '1' && e.Key[1] <= '4' { | |||
| 197 | + | final := string(rune('P' + e.Key[1] - '1')) | |||
| 198 | + | if modifier > 1 { | |||
| 199 | + | return fmt.Sprintf("\x1b[1;%d%s", modifier, final) | |||
| 200 | + | } | |||
| 201 | + | return "\x1bO" + final | |||
| 202 | + | } | |||
| 203 | + | var key string | |||
| 204 | + | switch e.Key { | |||
| 205 | + | case "Enter": | |||
| 206 | + | key = "\r" | |||
| 207 | + | case "Escape": | |||
| 208 | + | key = "\x1b" | |||
| 209 | + | case "Backspace": | |||
| 210 | + | key = "\x7f" | |||
| 211 | + | case "Tab": | |||
| 212 | + | if e.Modifiers.Shift { | |||
| 213 | + | if e.Modifiers.Alt { | |||
| 214 | + | return "\x1b\x1b[Z" | |||
| 215 | + | } | |||
| 216 | + | return "\x1b[Z" | |||
| 217 | + | } | |||
| 218 | + | key = "\t" | |||
| 219 | + | default: | |||
| 220 | + | if utf8.RuneCountInString(e.Key) != 1 { | |||
| 221 | + | return "" | |||
| 222 | + | } | |||
| 223 | + | key = cleanText(e.Key, false) | |||
| 224 | + | if e.Modifiers.Ctrl { | |||
| 225 | + | upper := strings.ToUpper(key) | |||
| 226 | + | if upper == " " || upper == "@" { | |||
| 227 | + | key = "\x00" | |||
| 228 | + | } else if len(upper) == 1 && upper[0] >= 'A' && upper[0] <= '_' { | |||
| 229 | + | key = string([]byte{upper[0] & 31}) | |||
| 230 | + | } else { | |||
| 231 | + | return "" | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | if e.Modifiers.Alt { | |||
| 236 | + | key = "\x1b" + key | |||
| 237 | + | } | |||
| 238 | + | return key | |||
| 239 | + | } | |||
A internal/adapters/webterm/proxy_test.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "net/url" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/coder/websocket" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestWebProxyTrustBoundary(t *testing.T) { | |
| 17 | + | for _, scheme := range []string{"http", "https"} { | |
| 18 | + | t.Run(scheme, func(t *testing.T) { | |
| 19 | + | settings, err := ParseSettings("", scheme+"://terminal.example.internal") | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | token := strings.Repeat("f", 43) | |
| 22 | + | handler, err := NewServer(t.Context(), settings, token, func(context.Context, *Terminal) error { return nil }) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | for _, tt := range []struct { | |
| 25 | + | name, host, origin string | |
| 26 | + | want int | |
| 27 | + | }{ | |
| 28 | + | {"valid", settings.Host(), settings.Origin, 204}, | |
| 29 | + | {"upstream host", Address, settings.Origin, 403}, | |
| 30 | + | {"foreign host", "attacker.invalid", settings.Origin, 403}, | |
| 31 | + | {"foreign origin", settings.Host(), "https://attacker.invalid", 403}, | |
| 32 | + | {"missing origin", settings.Host(), "", 403}, | |
| 33 | + | } { | |
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |
| 35 | + | r := httptest.NewRequest("POST", "http://"+Address+"/login", strings.NewReader(url.Values{"token": {token}}.Encode())) | |
| 36 | + | r.Host = tt.host | |
| 37 | + | r.Header.Set("Origin", tt.origin) | |
| 38 | + | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 39 | + | r.Header.Set("X-Forwarded-Host", settings.Host()) | |
| 40 | + | r.Header.Set("X-Forwarded-Proto", "https") | |
| 41 | + | w := httptest.NewRecorder() | |
| 42 | + | handler.ServeHTTP(w, r) | |
| 43 | + | require.Equal(t, tt.want, w.Code) | |
| 44 | + | if tt.want == 204 { | |
| 45 | + | cookies := w.Result().Cookies() | |
| 46 | + | require.Len(t, cookies, 1) | |
| 47 | + | require.Equal(t, scheme == "https", cookies[0].Secure) | |
| 48 | + | require.True(t, cookies[0].HttpOnly) | |
| 49 | + | require.Equal(t, http.SameSiteStrictMode, cookies[0].SameSite) | |
| 50 | + | } else { | |
| 51 | + | require.Empty(t, w.Result().Cookies()) | |
| 52 | + | } | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestWebProxyWebSocket(t *testing.T) { | |
| 60 | + | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) | |
| 61 | + | defer cancel() | |
| 62 | + | settings, err := ParseSettings("", "https://terminal.example.internal") | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | token := strings.Repeat("g", 43) | |
| 65 | + | handler, err := NewServer(ctx, settings, token, func(ctx context.Context, terminal *Terminal) error { | |
| 66 | + | <-ctx.Done() | |
| 67 | + | return ctx.Err() | |
| 68 | + | }) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | // Model a proxy that preserves the browser-facing Host on HTTP upstream. | |
| 71 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 72 | + | r.Host = settings.Host() | |
| 73 | + | handler.ServeHTTP(w, r) | |
| 74 | + | })) | |
| 75 | + | defer server.Close() | |
| 76 | + | headers := http.Header{"Origin": {settings.Origin}, "Cookie": {cookieName + "=" + token}} | |
| 77 | + | conn, _, err := websocket.Dial(ctx, server.URL+"/ws", &websocket.DialOptions{HTTPHeader: headers}) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | conn.CloseNow() | |
| 80 | + | cancel() | |
| 81 | + | handler.Wait() | |
| 82 | + | } | |
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "net/url" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/coder/websocket" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestWebProxyTrustBoundary(t *testing.T) { | |||
| 17 | + | for _, scheme := range []string{"http", "https"} { | |||
| 18 | + | t.Run(scheme, func(t *testing.T) { | |||
| 19 | + | settings, err := ParseSettings("", scheme+"://terminal.example.internal") | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | token := strings.Repeat("f", 43) | |||
| 22 | + | handler, err := NewServer(t.Context(), settings, token, func(context.Context, *Terminal) error { return nil }) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | for _, tt := range []struct { | |||
| 25 | + | name, host, origin string | |||
| 26 | + | want int | |||
| 27 | + | }{ | |||
| 28 | + | {"valid", settings.Host(), settings.Origin, 204}, | |||
| 29 | + | {"upstream host", Address, settings.Origin, 403}, | |||
| 30 | + | {"foreign host", "attacker.invalid", settings.Origin, 403}, | |||
| 31 | + | {"foreign origin", settings.Host(), "https://attacker.invalid", 403}, | |||
| 32 | + | {"missing origin", settings.Host(), "", 403}, | |||
| 33 | + | } { | |||
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 35 | + | r := httptest.NewRequest("POST", "http://"+Address+"/login", strings.NewReader(url.Values{"token": {token}}.Encode())) | |||
| 36 | + | r.Host = tt.host | |||
| 37 | + | r.Header.Set("Origin", tt.origin) | |||
| 38 | + | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |||
| 39 | + | r.Header.Set("X-Forwarded-Host", settings.Host()) | |||
| 40 | + | r.Header.Set("X-Forwarded-Proto", "https") | |||
| 41 | + | w := httptest.NewRecorder() | |||
| 42 | + | handler.ServeHTTP(w, r) | |||
| 43 | + | require.Equal(t, tt.want, w.Code) | |||
| 44 | + | if tt.want == 204 { | |||
| 45 | + | cookies := w.Result().Cookies() | |||
| 46 | + | require.Len(t, cookies, 1) | |||
| 47 | + | require.Equal(t, scheme == "https", cookies[0].Secure) | |||
| 48 | + | require.True(t, cookies[0].HttpOnly) | |||
| 49 | + | require.Equal(t, http.SameSiteStrictMode, cookies[0].SameSite) | |||
| 50 | + | } else { | |||
| 51 | + | require.Empty(t, w.Result().Cookies()) | |||
| 52 | + | } | |||
| 53 | + | }) | |||
| 54 | + | } | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestWebProxyWebSocket(t *testing.T) { | |||
| 60 | + | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) | |||
| 61 | + | defer cancel() | |||
| 62 | + | settings, err := ParseSettings("", "https://terminal.example.internal") | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | token := strings.Repeat("g", 43) | |||
| 65 | + | handler, err := NewServer(ctx, settings, token, func(ctx context.Context, terminal *Terminal) error { | |||
| 66 | + | <-ctx.Done() | |||
| 67 | + | return ctx.Err() | |||
| 68 | + | }) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | // Model a proxy that preserves the browser-facing Host on HTTP upstream. | |||
| 71 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 72 | + | r.Host = settings.Host() | |||
| 73 | + | handler.ServeHTTP(w, r) | |||
| 74 | + | })) | |||
| 75 | + | defer server.Close() | |||
| 76 | + | headers := http.Header{"Origin": {settings.Origin}, "Cookie": {cookieName + "=" + token}} | |||
| 77 | + | conn, _, err := websocket.Dial(ctx, server.URL+"/ws", &websocket.DialOptions{HTTPHeader: headers}) | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | conn.CloseNow() | |||
| 80 | + | cancel() | |||
| 81 | + | handler.Wait() | |||
| 82 | + | } | |||
A internal/adapters/webterm/rotation_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/coder/websocket" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRotationRevokesViewsAndCookies(t *testing.T) { | |
| 16 | + | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) | |
| 17 | + | defer cancel() | |
| 18 | + | token, err := NewToken() | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | h, err := NewServer(ctx, Settings{}, token, func(ctx context.Context, terminal *Terminal) error { | |
| 21 | + | if err := terminal.Flush(); err != nil { | |
| 22 | + | return err | |
| 23 | + | } | |
| 24 | + | <-ctx.Done() | |
| 25 | + | return ctx.Err() | |
| 26 | + | }) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Host = Address; h.ServeHTTP(w, r) })) | |
| 29 | + | defer server.Close() | |
| 30 | + | conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http")+"/ws", &websocket.DialOptions{HTTPHeader: http.Header{"Origin": {Origin}, "Cookie": {cookieName + "=" + token}}}) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | defer conn.CloseNow() | |
| 33 | + | conn.SetReadLimit(64 << 20) | |
| 34 | + | _, _, err = conn.Read(ctx) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | views := func(credential string) *httptest.ResponseRecorder { | |
| 37 | + | r := httptest.NewRequest(http.MethodGet, Origin+"/views", nil) | |
| 38 | + | r.AddCookie(&http.Cookie{Name: cookieName, Value: credential}) | |
| 39 | + | w := httptest.NewRecorder() | |
| 40 | + | h.ServeHTTP(w, r) | |
| 41 | + | return w | |
| 42 | + | } | |
| 43 | + | require.JSONEq(t, `{"active":1}`, views(token).Body.String()) | |
| 44 | + | renewed, err := h.RenewToken() | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | require.NotEqual(t, token, renewed) | |
| 47 | + | require.Equal(t, http.StatusUnauthorized, views(token).Code) | |
| 48 | + | for { | |
| 49 | + | _, _, err = conn.Read(ctx) | |
| 50 | + | if err != nil { | |
| 51 | + | break | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | require.Error(t, err) | |
| 55 | + | h.Wait() | |
| 56 | + | require.JSONEq(t, `{"active":0}`, views(renewed).Body.String()) | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/coder/websocket" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestRotationRevokesViewsAndCookies(t *testing.T) { | |||
| 16 | + | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) | |||
| 17 | + | defer cancel() | |||
| 18 | + | token, err := NewToken() | |||
| 19 | + | require.NoError(t, err) | |||
| 20 | + | h, err := NewServer(ctx, Settings{}, token, func(ctx context.Context, terminal *Terminal) error { | |||
| 21 | + | if err := terminal.Flush(); err != nil { | |||
| 22 | + | return err | |||
| 23 | + | } | |||
| 24 | + | <-ctx.Done() | |||
| 25 | + | return ctx.Err() | |||
| 26 | + | }) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Host = Address; h.ServeHTTP(w, r) })) | |||
| 29 | + | defer server.Close() | |||
| 30 | + | conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http")+"/ws", &websocket.DialOptions{HTTPHeader: http.Header{"Origin": {Origin}, "Cookie": {cookieName + "=" + token}}}) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | defer conn.CloseNow() | |||
| 33 | + | conn.SetReadLimit(64 << 20) | |||
| 34 | + | _, _, err = conn.Read(ctx) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | views := func(credential string) *httptest.ResponseRecorder { | |||
| 37 | + | r := httptest.NewRequest(http.MethodGet, Origin+"/views", nil) | |||
| 38 | + | r.AddCookie(&http.Cookie{Name: cookieName, Value: credential}) | |||
| 39 | + | w := httptest.NewRecorder() | |||
| 40 | + | h.ServeHTTP(w, r) | |||
| 41 | + | return w | |||
| 42 | + | } | |||
| 43 | + | require.JSONEq(t, `{"active":1}`, views(token).Body.String()) | |||
| 44 | + | renewed, err := h.RenewToken() | |||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | require.NotEqual(t, token, renewed) | |||
| 47 | + | require.Equal(t, http.StatusUnauthorized, views(token).Code) | |||
| 48 | + | for { | |||
| 49 | + | _, _, err = conn.Read(ctx) | |||
| 50 | + | if err != nil { | |||
| 51 | + | break | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | require.Error(t, err) | |||
| 55 | + | h.Wait() | |||
| 56 | + | require.JSONEq(t, `{"active":0}`, views(renewed).Body.String()) | |||
| 57 | + | } | |||
A internal/adapters/webterm/server.go +352 -0
| @@ -0,0 +1,352 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/rand" | |
| 6 | + | "crypto/subtle" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "net" | |
| 12 | + | "net/http" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/vev-vt/core" | |
| 18 | + | htmlrenderer "github.com/bnema/vev-vt/html" | |
| 19 | + | "github.com/bnema/vev-vt/html/browser" | |
| 20 | + | "github.com/bnema/vev/internal/domain" | |
| 21 | + | "github.com/coder/websocket" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | const ( | |
| 25 | + | Address = "127.0.0.1:8778" | |
| 26 | + | Origin = "http://" + Address | |
| 27 | + | cookieName = "vev-web-session" | |
| 28 | + | maxConnections = 8 | |
| 29 | + | maxEventBytes = 8 << 20 | |
| 30 | + | writeTimeout = 5 * time.Second | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | // RunTerminal composes a normal client attachment. Returning ends its socket. | |
| 34 | + | type RunTerminal func(context.Context, *Terminal) error | |
| 35 | + | ||
| 36 | + | type Server struct { | |
| 37 | + | settings Settings | |
| 38 | + | ctx context.Context | |
| 39 | + | token string | |
| 40 | + | authCtx context.Context | |
| 41 | + | authCancel context.CancelFunc | |
| 42 | + | run RunTerminal | |
| 43 | + | slots chan struct{} | |
| 44 | + | workers sync.WaitGroup | |
| 45 | + | mu sync.Mutex | |
| 46 | + | stopping bool | |
| 47 | + | activeViews int | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func NewServer(ctx context.Context, settings Settings, token string, run RunTerminal) (*Server, error) { | |
| 51 | + | settings, err := ParseSettings(settings.Listen, settings.Origin) | |
| 52 | + | if err != nil { | |
| 53 | + | return nil, err | |
| 54 | + | } | |
| 55 | + | if len(token) < 32 || run == nil { | |
| 56 | + | return nil, errors.New("webterm: invalid server configuration") | |
| 57 | + | } | |
| 58 | + | authCtx, authCancel := context.WithCancel(ctx) | |
| 59 | + | return &Server{settings: settings, ctx: ctx, token: token, authCtx: authCtx, authCancel: authCancel, run: run, slots: make(chan struct{}, maxConnections)}, nil | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Wait closes admission before draining all handlers, including handshakes. | |
| 63 | + | // The owner cancels the server context before calling Wait. | |
| 64 | + | func (s *Server) Wait() { | |
| 65 | + | s.mu.Lock() | |
| 66 | + | s.stopping = true | |
| 67 | + | s.mu.Unlock() | |
| 68 | + | s.workers.Wait() | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (s *Server) admit() bool { | |
| 72 | + | s.mu.Lock() | |
| 73 | + | defer s.mu.Unlock() | |
| 74 | + | if s.stopping || s.ctx.Err() != nil { | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | s.workers.Add(1) | |
| 78 | + | return true | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 82 | + | w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") | |
| 83 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 84 | + | w.Header().Set("Referrer-Policy", "no-referrer") | |
| 85 | + | w.Header().Set("Cache-Control", "no-store") | |
| 86 | + | if !strings.EqualFold(normalizeHost(r.Host), normalizeHost(s.settings.Host())) || r.Header.Get("Sec-Fetch-Site") == "cross-site" { | |
| 87 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | if origin := r.Header.Get("Origin"); origin != "" && origin != s.settings.Origin { | |
| 91 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | switch r.URL.Path { | |
| 95 | + | case "/": | |
| 96 | + | if r.Method != http.MethodGet { | |
| 97 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 101 | + | _, _ = w.Write([]byte(indexHTML)) | |
| 102 | + | return | |
| 103 | + | case "/icons-license": | |
| 104 | + | if r.Method != http.MethodGet { | |
| 105 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 109 | + | _, _ = w.Write([]byte(iconsLicense)) | |
| 110 | + | return | |
| 111 | + | case "/app.js": | |
| 112 | + | if r.Method != http.MethodGet { | |
| 113 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | w.Header().Set("Content-Type", "text/javascript; charset=utf-8") | |
| 117 | + | _, _ = w.Write([]byte(appJS)) | |
| 118 | + | return | |
| 119 | + | case "/terminal.js": | |
| 120 | + | if r.Method != http.MethodGet { | |
| 121 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | w.Header().Set("Content-Type", "text/javascript; charset=utf-8") | |
| 125 | + | _, _ = w.Write([]byte(browser.JavaScript())) | |
| 126 | + | return | |
| 127 | + | case "/terminal.css": | |
| 128 | + | if r.Method != http.MethodGet { | |
| 129 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | w.Header().Set("Content-Type", "text/css; charset=utf-8") | |
| 133 | + | _, _ = w.Write([]byte(htmlrenderer.Stylesheet() + appCSS)) | |
| 134 | + | return | |
| 135 | + | case "/login": | |
| 136 | + | if r.Method != http.MethodPost || r.Header.Get("Origin") != s.settings.Origin { | |
| 137 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | r.Body = http.MaxBytesReader(w, r.Body, 1024) | |
| 141 | + | if err := r.ParseForm(); err != nil || !s.valid(r.PostForm.Get("token")) { | |
| 142 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | http.SetCookie(w, &http.Cookie{Name: cookieName, Value: r.PostForm.Get("token"), Path: "/", HttpOnly: true, Secure: strings.HasPrefix(s.settings.Origin, "https://"), SameSite: http.SameSiteStrictMode}) | |
| 146 | + | w.WriteHeader(http.StatusNoContent) | |
| 147 | + | return | |
| 148 | + | case "/health", "/ws", "/views": | |
| 149 | + | cookie, err := r.Cookie(cookieName) | |
| 150 | + | if err != nil || !s.valid(cookie.Value) { | |
| 151 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | if r.Method != http.MethodGet { | |
| 155 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | if r.URL.Path == "/views" { | |
| 159 | + | s.mu.Lock() | |
| 160 | + | count := s.activeViews | |
| 161 | + | s.mu.Unlock() | |
| 162 | + | w.Header().Set("Content-Type", "application/json") | |
| 163 | + | _ = json.NewEncoder(w).Encode(struct { | |
| 164 | + | Active int `json:"active"` | |
| 165 | + | }{count}) | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | if r.URL.Path == "/health" { | |
| 169 | + | w.WriteHeader(http.StatusNoContent) | |
| 170 | + | return | |
| 171 | + | } | |
| 172 | + | if r.Header.Get("Origin") != s.settings.Origin { | |
| 173 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | s.serveSocket(w, r) | |
| 177 | + | default: | |
| 178 | + | http.NotFound(w, r) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // normalizeHost trims one trailing dot from the hostname portion of a Host | |
| 183 | + | // value so the legal FQDN form ("example.internal.:8778") matches the | |
| 184 | + | // normalized origin host. | |
| 185 | + | func normalizeHost(host string) string { | |
| 186 | + | hostname, port, err := net.SplitHostPort(host) | |
| 187 | + | if err != nil { | |
| 188 | + | return strings.TrimSuffix(host, ".") | |
| 189 | + | } | |
| 190 | + | return net.JoinHostPort(strings.TrimSuffix(hostname, "."), port) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (s *Server) valid(token string) bool { | |
| 194 | + | s.mu.Lock() | |
| 195 | + | defer s.mu.Unlock() | |
| 196 | + | return subtle.ConstantTimeCompare([]byte(token), []byte(s.token)) == 1 | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Token returns the current credential to the trusted local control boundary. | |
| 200 | + | func (s *Server) Token() string { | |
| 201 | + | s.mu.Lock() | |
| 202 | + | defer s.mu.Unlock() | |
| 203 | + | return s.token | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // RenewToken revokes existing cookies and attachments without stopping shells. | |
| 207 | + | func (s *Server) RenewToken() (string, error) { | |
| 208 | + | token, err := NewToken() | |
| 209 | + | if err != nil { | |
| 210 | + | return "", err | |
| 211 | + | } | |
| 212 | + | s.mu.Lock() | |
| 213 | + | defer s.mu.Unlock() | |
| 214 | + | s.authCancel() | |
| 215 | + | s.authCtx, s.authCancel = context.WithCancel(s.ctx) | |
| 216 | + | s.token = token | |
| 217 | + | return token, nil | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func NewToken() (string, error) { | |
| 221 | + | var data [32]byte | |
| 222 | + | if _, err := rand.Read(data[:]); err != nil { | |
| 223 | + | return "", err | |
| 224 | + | } | |
| 225 | + | return base64.RawURLEncoding.EncodeToString(data[:]), nil | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func (s *Server) serveSocket(w http.ResponseWriter, r *http.Request) { | |
| 229 | + | if !s.admit() { | |
| 230 | + | http.Error(w, "Server stopping", http.StatusServiceUnavailable) | |
| 231 | + | return | |
| 232 | + | } | |
| 233 | + | defer s.workers.Done() | |
| 234 | + | select { | |
| 235 | + | case s.slots <- struct{}{}: | |
| 236 | + | default: | |
| 237 | + | http.Error(w, "Too many terminals", http.StatusServiceUnavailable) | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | defer func() { <-s.slots }() | |
| 241 | + | // Bind this handshake to the authenticated generation under the same lock | |
| 242 | + | // as rotation. A rotated credential cannot open a new-generation socket. | |
| 243 | + | s.mu.Lock() | |
| 244 | + | cookie, cookieErr := r.Cookie(cookieName) | |
| 245 | + | if cookieErr != nil || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(s.token)) != 1 { | |
| 246 | + | s.mu.Unlock() | |
| 247 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |
| 248 | + | return | |
| 249 | + | } | |
| 250 | + | ctx, cancel := context.WithCancel(s.authCtx) | |
| 251 | + | s.mu.Unlock() | |
| 252 | + | defer cancel() | |
| 253 | + | conn, err := websocket.Accept(w, r, nil) | |
| 254 | + | if err != nil { | |
| 255 | + | return | |
| 256 | + | } | |
| 257 | + | defer conn.CloseNow() | |
| 258 | + | s.mu.Lock() | |
| 259 | + | s.activeViews++ | |
| 260 | + | s.mu.Unlock() | |
| 261 | + | defer func() { s.mu.Lock(); s.activeViews--; s.mu.Unlock() }() | |
| 262 | + | conn.SetReadLimit(maxEventBytes) | |
| 263 | + | stop := context.AfterFunc(ctx, func() { _ = conn.CloseNow() }) | |
| 264 | + | defer stop() | |
| 265 | + | terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 266 | + | if err != nil { | |
| 267 | + | return | |
| 268 | + | } | |
| 269 | + | defer terminal.Close() | |
| 270 | + | done := make(chan error, 2) | |
| 271 | + | go func() { done <- s.run(ctx, terminal) }() | |
| 272 | + | go func() { done <- readEvents(ctx, conn, terminal) }() | |
| 273 | + | err = writeUpdates(ctx, conn, terminal, done) | |
| 274 | + | cancel() | |
| 275 | + | terminal.Close() | |
| 276 | + | // writeUpdates consumes at most one worker result. | |
| 277 | + | remaining := 2 | |
| 278 | + | var ended *workerEnded | |
| 279 | + | if errors.As(err, &ended) { | |
| 280 | + | remaining-- | |
| 281 | + | } | |
| 282 | + | for range remaining { | |
| 283 | + | <-done | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | type workerEnded struct{ err error } | |
| 288 | + | ||
| 289 | + | func (e *workerEnded) Error() string { return "webterm: attachment ended" } | |
| 290 | + | ||
| 291 | + | func readEvents(ctx context.Context, conn *websocket.Conn, terminal *Terminal) error { | |
| 292 | + | for { | |
| 293 | + | kind, data, err := conn.Read(ctx) | |
| 294 | + | if err != nil { | |
| 295 | + | return err | |
| 296 | + | } | |
| 297 | + | if kind != websocket.MessageText { | |
| 298 | + | return errors.New("webterm: expected text event") | |
| 299 | + | } | |
| 300 | + | event, err := browser.DecodeEvent(data, browser.EventLimits{MaxColumns: MaxColumns, MaxRows: MaxRows}) | |
| 301 | + | if err != nil { | |
| 302 | + | return err | |
| 303 | + | } | |
| 304 | + | if err := terminal.Handle(ctx, event); err != nil { | |
| 305 | + | return err | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func writeUpdates(ctx context.Context, conn *websocket.Conn, terminal *Terminal, done <-chan error) error { | |
| 311 | + | renderer, err := htmlrenderer.New(htmlrenderer.Options{}) | |
| 312 | + | if err != nil { | |
| 313 | + | return err | |
| 314 | + | } | |
| 315 | + | for { | |
| 316 | + | select { | |
| 317 | + | case <-ctx.Done(): | |
| 318 | + | return ctx.Err() | |
| 319 | + | case err := <-done: | |
| 320 | + | return &workerEnded{err: err} | |
| 321 | + | case <-terminal.Changes(): | |
| 322 | + | } | |
| 323 | + | snapshot := terminal.Snapshot() | |
| 324 | + | frame := core.NewFrame(snapshot.Columns(), snapshot.Rows()) | |
| 325 | + | for y := range snapshot.Rows() { | |
| 326 | + | frame.WriteRow(y, 0, snapshot.Row(y)) | |
| 327 | + | } | |
| 328 | + | cursor := snapshot.Cursor() | |
| 329 | + | prepared, err := renderer.Prepare(frame, nil, false, htmlrenderer.Cursor{Row: cursor.Row, Column: cursor.Col, Visible: cursor.Visible, Style: htmlrenderer.CursorStyle(cursor.Style), StyleSet: cursor.StyleSet}) | |
| 330 | + | if err != nil { | |
| 331 | + | return fmt.Errorf("webterm: prepare: %w", err) | |
| 332 | + | } | |
| 333 | + | payload, err := json.Marshal(struct { | |
| 334 | + | Update json.RawMessage `json:"update"` | |
| 335 | + | Mouse bool `json:"mouse"` | |
| 336 | + | }{Update: prepared.JSON(), Mouse: snapshot.Modes().MouseTracking != 0}) | |
| 337 | + | if err != nil { | |
| 338 | + | _ = prepared.Abort() | |
| 339 | + | return err | |
| 340 | + | } | |
| 341 | + | writeCtx, cancel := context.WithTimeout(ctx, writeTimeout) | |
| 342 | + | err = conn.Write(writeCtx, websocket.MessageText, payload) | |
| 343 | + | cancel() | |
| 344 | + | if err != nil { | |
| 345 | + | _ = prepared.Abort() | |
| 346 | + | return err | |
| 347 | + | } | |
| 348 | + | if err := prepared.Commit(); err != nil { | |
| 349 | + | return err | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | } | |
| @@ -0,0 +1,352 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/rand" | |||
| 6 | + | "crypto/subtle" | |||
| 7 | + | "encoding/base64" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "net" | |||
| 12 | + | "net/http" | |||
| 13 | + | "strings" | |||
| 14 | + | "sync" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/vev-vt/core" | |||
| 18 | + | htmlrenderer "github.com/bnema/vev-vt/html" | |||
| 19 | + | "github.com/bnema/vev-vt/html/browser" | |||
| 20 | + | "github.com/bnema/vev/internal/domain" | |||
| 21 | + | "github.com/coder/websocket" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | const ( | |||
| 25 | + | Address = "127.0.0.1:8778" | |||
| 26 | + | Origin = "http://" + Address | |||
| 27 | + | cookieName = "vev-web-session" | |||
| 28 | + | maxConnections = 8 | |||
| 29 | + | maxEventBytes = 8 << 20 | |||
| 30 | + | writeTimeout = 5 * time.Second | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | // RunTerminal composes a normal client attachment. Returning ends its socket. | |||
| 34 | + | type RunTerminal func(context.Context, *Terminal) error | |||
| 35 | + | ||||
| 36 | + | type Server struct { | |||
| 37 | + | settings Settings | |||
| 38 | + | ctx context.Context | |||
| 39 | + | token string | |||
| 40 | + | authCtx context.Context | |||
| 41 | + | authCancel context.CancelFunc | |||
| 42 | + | run RunTerminal | |||
| 43 | + | slots chan struct{} | |||
| 44 | + | workers sync.WaitGroup | |||
| 45 | + | mu sync.Mutex | |||
| 46 | + | stopping bool | |||
| 47 | + | activeViews int | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func NewServer(ctx context.Context, settings Settings, token string, run RunTerminal) (*Server, error) { | |||
| 51 | + | settings, err := ParseSettings(settings.Listen, settings.Origin) | |||
| 52 | + | if err != nil { | |||
| 53 | + | return nil, err | |||
| 54 | + | } | |||
| 55 | + | if len(token) < 32 || run == nil { | |||
| 56 | + | return nil, errors.New("webterm: invalid server configuration") | |||
| 57 | + | } | |||
| 58 | + | authCtx, authCancel := context.WithCancel(ctx) | |||
| 59 | + | return &Server{settings: settings, ctx: ctx, token: token, authCtx: authCtx, authCancel: authCancel, run: run, slots: make(chan struct{}, maxConnections)}, nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Wait closes admission before draining all handlers, including handshakes. | |||
| 63 | + | // The owner cancels the server context before calling Wait. | |||
| 64 | + | func (s *Server) Wait() { | |||
| 65 | + | s.mu.Lock() | |||
| 66 | + | s.stopping = true | |||
| 67 | + | s.mu.Unlock() | |||
| 68 | + | s.workers.Wait() | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (s *Server) admit() bool { | |||
| 72 | + | s.mu.Lock() | |||
| 73 | + | defer s.mu.Unlock() | |||
| 74 | + | if s.stopping || s.ctx.Err() != nil { | |||
| 75 | + | return false | |||
| 76 | + | } | |||
| 77 | + | s.workers.Add(1) | |||
| 78 | + | return true | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 82 | + | w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") | |||
| 83 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |||
| 84 | + | w.Header().Set("Referrer-Policy", "no-referrer") | |||
| 85 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 86 | + | if !strings.EqualFold(normalizeHost(r.Host), normalizeHost(s.settings.Host())) || r.Header.Get("Sec-Fetch-Site") == "cross-site" { | |||
| 87 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | if origin := r.Header.Get("Origin"); origin != "" && origin != s.settings.Origin { | |||
| 91 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | switch r.URL.Path { | |||
| 95 | + | case "/": | |||
| 96 | + | if r.Method != http.MethodGet { | |||
| 97 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 100 | + | w.Header().Set("Content-Type", "text/html; charset=utf-8") | |||
| 101 | + | _, _ = w.Write([]byte(indexHTML)) | |||
| 102 | + | return | |||
| 103 | + | case "/icons-license": | |||
| 104 | + | if r.Method != http.MethodGet { | |||
| 105 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |||
| 109 | + | _, _ = w.Write([]byte(iconsLicense)) | |||
| 110 | + | return | |||
| 111 | + | case "/app.js": | |||
| 112 | + | if r.Method != http.MethodGet { | |||
| 113 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | w.Header().Set("Content-Type", "text/javascript; charset=utf-8") | |||
| 117 | + | _, _ = w.Write([]byte(appJS)) | |||
| 118 | + | return | |||
| 119 | + | case "/terminal.js": | |||
| 120 | + | if r.Method != http.MethodGet { | |||
| 121 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | w.Header().Set("Content-Type", "text/javascript; charset=utf-8") | |||
| 125 | + | _, _ = w.Write([]byte(browser.JavaScript())) | |||
| 126 | + | return | |||
| 127 | + | case "/terminal.css": | |||
| 128 | + | if r.Method != http.MethodGet { | |||
| 129 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | w.Header().Set("Content-Type", "text/css; charset=utf-8") | |||
| 133 | + | _, _ = w.Write([]byte(htmlrenderer.Stylesheet() + appCSS)) | |||
| 134 | + | return | |||
| 135 | + | case "/login": | |||
| 136 | + | if r.Method != http.MethodPost || r.Header.Get("Origin") != s.settings.Origin { | |||
| 137 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | r.Body = http.MaxBytesReader(w, r.Body, 1024) | |||
| 141 | + | if err := r.ParseForm(); err != nil || !s.valid(r.PostForm.Get("token")) { | |||
| 142 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |||
| 143 | + | return | |||
| 144 | + | } | |||
| 145 | + | http.SetCookie(w, &http.Cookie{Name: cookieName, Value: r.PostForm.Get("token"), Path: "/", HttpOnly: true, Secure: strings.HasPrefix(s.settings.Origin, "https://"), SameSite: http.SameSiteStrictMode}) | |||
| 146 | + | w.WriteHeader(http.StatusNoContent) | |||
| 147 | + | return | |||
| 148 | + | case "/health", "/ws", "/views": | |||
| 149 | + | cookie, err := r.Cookie(cookieName) | |||
| 150 | + | if err != nil || !s.valid(cookie.Value) { | |||
| 151 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | if r.Method != http.MethodGet { | |||
| 155 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | if r.URL.Path == "/views" { | |||
| 159 | + | s.mu.Lock() | |||
| 160 | + | count := s.activeViews | |||
| 161 | + | s.mu.Unlock() | |||
| 162 | + | w.Header().Set("Content-Type", "application/json") | |||
| 163 | + | _ = json.NewEncoder(w).Encode(struct { | |||
| 164 | + | Active int `json:"active"` | |||
| 165 | + | }{count}) | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | if r.URL.Path == "/health" { | |||
| 169 | + | w.WriteHeader(http.StatusNoContent) | |||
| 170 | + | return | |||
| 171 | + | } | |||
| 172 | + | if r.Header.Get("Origin") != s.settings.Origin { | |||
| 173 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |||
| 174 | + | return | |||
| 175 | + | } | |||
| 176 | + | s.serveSocket(w, r) | |||
| 177 | + | default: | |||
| 178 | + | http.NotFound(w, r) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // normalizeHost trims one trailing dot from the hostname portion of a Host | |||
| 183 | + | // value so the legal FQDN form ("example.internal.:8778") matches the | |||
| 184 | + | // normalized origin host. | |||
| 185 | + | func normalizeHost(host string) string { | |||
| 186 | + | hostname, port, err := net.SplitHostPort(host) | |||
| 187 | + | if err != nil { | |||
| 188 | + | return strings.TrimSuffix(host, ".") | |||
| 189 | + | } | |||
| 190 | + | return net.JoinHostPort(strings.TrimSuffix(hostname, "."), port) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func (s *Server) valid(token string) bool { | |||
| 194 | + | s.mu.Lock() | |||
| 195 | + | defer s.mu.Unlock() | |||
| 196 | + | return subtle.ConstantTimeCompare([]byte(token), []byte(s.token)) == 1 | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // Token returns the current credential to the trusted local control boundary. | |||
| 200 | + | func (s *Server) Token() string { | |||
| 201 | + | s.mu.Lock() | |||
| 202 | + | defer s.mu.Unlock() | |||
| 203 | + | return s.token | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // RenewToken revokes existing cookies and attachments without stopping shells. | |||
| 207 | + | func (s *Server) RenewToken() (string, error) { | |||
| 208 | + | token, err := NewToken() | |||
| 209 | + | if err != nil { | |||
| 210 | + | return "", err | |||
| 211 | + | } | |||
| 212 | + | s.mu.Lock() | |||
| 213 | + | defer s.mu.Unlock() | |||
| 214 | + | s.authCancel() | |||
| 215 | + | s.authCtx, s.authCancel = context.WithCancel(s.ctx) | |||
| 216 | + | s.token = token | |||
| 217 | + | return token, nil | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func NewToken() (string, error) { | |||
| 221 | + | var data [32]byte | |||
| 222 | + | if _, err := rand.Read(data[:]); err != nil { | |||
| 223 | + | return "", err | |||
| 224 | + | } | |||
| 225 | + | return base64.RawURLEncoding.EncodeToString(data[:]), nil | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func (s *Server) serveSocket(w http.ResponseWriter, r *http.Request) { | |||
| 229 | + | if !s.admit() { | |||
| 230 | + | http.Error(w, "Server stopping", http.StatusServiceUnavailable) | |||
| 231 | + | return | |||
| 232 | + | } | |||
| 233 | + | defer s.workers.Done() | |||
| 234 | + | select { | |||
| 235 | + | case s.slots <- struct{}{}: | |||
| 236 | + | default: | |||
| 237 | + | http.Error(w, "Too many terminals", http.StatusServiceUnavailable) | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | defer func() { <-s.slots }() | |||
| 241 | + | // Bind this handshake to the authenticated generation under the same lock | |||
| 242 | + | // as rotation. A rotated credential cannot open a new-generation socket. | |||
| 243 | + | s.mu.Lock() | |||
| 244 | + | cookie, cookieErr := r.Cookie(cookieName) | |||
| 245 | + | if cookieErr != nil || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(s.token)) != 1 { | |||
| 246 | + | s.mu.Unlock() | |||
| 247 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |||
| 248 | + | return | |||
| 249 | + | } | |||
| 250 | + | ctx, cancel := context.WithCancel(s.authCtx) | |||
| 251 | + | s.mu.Unlock() | |||
| 252 | + | defer cancel() | |||
| 253 | + | conn, err := websocket.Accept(w, r, nil) | |||
| 254 | + | if err != nil { | |||
| 255 | + | return | |||
| 256 | + | } | |||
| 257 | + | defer conn.CloseNow() | |||
| 258 | + | s.mu.Lock() | |||
| 259 | + | s.activeViews++ | |||
| 260 | + | s.mu.Unlock() | |||
| 261 | + | defer func() { s.mu.Lock(); s.activeViews--; s.mu.Unlock() }() | |||
| 262 | + | conn.SetReadLimit(maxEventBytes) | |||
| 263 | + | stop := context.AfterFunc(ctx, func() { _ = conn.CloseNow() }) | |||
| 264 | + | defer stop() | |||
| 265 | + | terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) | |||
| 266 | + | if err != nil { | |||
| 267 | + | return | |||
| 268 | + | } | |||
| 269 | + | defer terminal.Close() | |||
| 270 | + | done := make(chan error, 2) | |||
| 271 | + | go func() { done <- s.run(ctx, terminal) }() | |||
| 272 | + | go func() { done <- readEvents(ctx, conn, terminal) }() | |||
| 273 | + | err = writeUpdates(ctx, conn, terminal, done) | |||
| 274 | + | cancel() | |||
| 275 | + | terminal.Close() | |||
| 276 | + | // writeUpdates consumes at most one worker result. | |||
| 277 | + | remaining := 2 | |||
| 278 | + | var ended *workerEnded | |||
| 279 | + | if errors.As(err, &ended) { | |||
| 280 | + | remaining-- | |||
| 281 | + | } | |||
| 282 | + | for range remaining { | |||
| 283 | + | <-done | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | type workerEnded struct{ err error } | |||
| 288 | + | ||||
| 289 | + | func (e *workerEnded) Error() string { return "webterm: attachment ended" } | |||
| 290 | + | ||||
| 291 | + | func readEvents(ctx context.Context, conn *websocket.Conn, terminal *Terminal) error { | |||
| 292 | + | for { | |||
| 293 | + | kind, data, err := conn.Read(ctx) | |||
| 294 | + | if err != nil { | |||
| 295 | + | return err | |||
| 296 | + | } | |||
| 297 | + | if kind != websocket.MessageText { | |||
| 298 | + | return errors.New("webterm: expected text event") | |||
| 299 | + | } | |||
| 300 | + | event, err := browser.DecodeEvent(data, browser.EventLimits{MaxColumns: MaxColumns, MaxRows: MaxRows}) | |||
| 301 | + | if err != nil { | |||
| 302 | + | return err | |||
| 303 | + | } | |||
| 304 | + | if err := terminal.Handle(ctx, event); err != nil { | |||
| 305 | + | return err | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func writeUpdates(ctx context.Context, conn *websocket.Conn, terminal *Terminal, done <-chan error) error { | |||
| 311 | + | renderer, err := htmlrenderer.New(htmlrenderer.Options{}) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return err | |||
| 314 | + | } | |||
| 315 | + | for { | |||
| 316 | + | select { | |||
| 317 | + | case <-ctx.Done(): | |||
| 318 | + | return ctx.Err() | |||
| 319 | + | case err := <-done: | |||
| 320 | + | return &workerEnded{err: err} | |||
| 321 | + | case <-terminal.Changes(): | |||
| 322 | + | } | |||
| 323 | + | snapshot := terminal.Snapshot() | |||
| 324 | + | frame := core.NewFrame(snapshot.Columns(), snapshot.Rows()) | |||
| 325 | + | for y := range snapshot.Rows() { | |||
| 326 | + | frame.WriteRow(y, 0, snapshot.Row(y)) | |||
| 327 | + | } | |||
| 328 | + | cursor := snapshot.Cursor() | |||
| 329 | + | prepared, err := renderer.Prepare(frame, nil, false, htmlrenderer.Cursor{Row: cursor.Row, Column: cursor.Col, Visible: cursor.Visible, Style: htmlrenderer.CursorStyle(cursor.Style), StyleSet: cursor.StyleSet}) | |||
| 330 | + | if err != nil { | |||
| 331 | + | return fmt.Errorf("webterm: prepare: %w", err) | |||
| 332 | + | } | |||
| 333 | + | payload, err := json.Marshal(struct { | |||
| 334 | + | Update json.RawMessage `json:"update"` | |||
| 335 | + | Mouse bool `json:"mouse"` | |||
| 336 | + | }{Update: prepared.JSON(), Mouse: snapshot.Modes().MouseTracking != 0}) | |||
| 337 | + | if err != nil { | |||
| 338 | + | _ = prepared.Abort() | |||
| 339 | + | return err | |||
| 340 | + | } | |||
| 341 | + | writeCtx, cancel := context.WithTimeout(ctx, writeTimeout) | |||
| 342 | + | err = conn.Write(writeCtx, websocket.MessageText, payload) | |||
| 343 | + | cancel() | |||
| 344 | + | if err != nil { | |||
| 345 | + | _ = prepared.Abort() | |||
| 346 | + | return err | |||
| 347 | + | } | |||
| 348 | + | if err := prepared.Commit(); err != nil { | |||
| 349 | + | return err | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | } | |||
A internal/adapters/webterm/server_test.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "io" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "net/url" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/coder/websocket" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestHTTPTrustBoundary(t *testing.T) { | |
| 19 | + | token := strings.Repeat("a", 43) | |
| 20 | + | server, err := NewServer(t.Context(), Settings{}, token, func(context.Context, *Terminal) error { return nil }) | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | tests := []struct { | |
| 23 | + | name, method, path, host, origin, site, credential string | |
| 24 | + | want int | |
| 25 | + | }{ | |
| 26 | + | {"shell", "GET", "/", Address, "", "", "", 200}, | |
| 27 | + | {"no auth", "GET", "/health", Address, "", "", "", 401}, | |
| 28 | + | {"authenticated", "GET", "/health", Address, "", "", token, 204}, | |
| 29 | + | {"rebinding", "GET", "/health", "attacker.invalid", "", "", token, 403}, | |
| 30 | + | {"cross origin", "GET", "/health", Address, "https://attacker.invalid", "", token, 403}, | |
| 31 | + | {"cross site", "GET", "/", Address, "", "cross-site", "", 403}, | |
| 32 | + | {"ws requires origin", "GET", "/ws", Address, "", "", token, 403}, | |
| 33 | + | {"health is read only", "POST", "/health", Address, Origin, "", token, 405}, | |
| 34 | + | {"license is read only", "POST", "/icons-license", Address, "", "", "", 405}, | |
| 35 | + | {"uppercase host", "GET", "/health", strings.ToUpper(Address), "", "", token, 204}, | |
| 36 | + | {"trailing dot host", "GET", "/health", "127.0.0.1.:8778", "", "", token, 204}, | |
| 37 | + | } | |
| 38 | + | for _, tt := range tests { | |
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |
| 40 | + | request := httptest.NewRequest(tt.method, Origin+tt.path, nil) | |
| 41 | + | request.Host = tt.host | |
| 42 | + | request.Header.Set("Origin", tt.origin) | |
| 43 | + | request.Header.Set("Sec-Fetch-Site", tt.site) | |
| 44 | + | if tt.credential != "" { | |
| 45 | + | request.AddCookie(&http.Cookie{Name: cookieName, Value: tt.credential}) | |
| 46 | + | } | |
| 47 | + | response := httptest.NewRecorder() | |
| 48 | + | server.ServeHTTP(response, request) | |
| 49 | + | require.Equal(t, tt.want, response.Code) | |
| 50 | + | require.Contains(t, response.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'") | |
| 51 | + | require.NotContains(t, response.Body.String(), token) | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestLogin(t *testing.T) { | |
| 57 | + | token := strings.Repeat("b", 43) | |
| 58 | + | server, err := NewServer(t.Context(), Settings{}, token, func(context.Context, *Terminal) error { return nil }) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | for _, valid := range []bool{false, true} { | |
| 61 | + | t.Run(map[bool]string{false: "invalid", true: "valid"}[valid], func(t *testing.T) { | |
| 62 | + | supplied := "wrong" | |
| 63 | + | if valid { | |
| 64 | + | supplied = token | |
| 65 | + | } | |
| 66 | + | request := httptest.NewRequest("POST", Origin+"/login", strings.NewReader(url.Values{"token": {supplied}}.Encode())) | |
| 67 | + | request.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 68 | + | request.Header.Set("Origin", Origin) | |
| 69 | + | response := httptest.NewRecorder() | |
| 70 | + | server.ServeHTTP(response, request) | |
| 71 | + | if !valid { | |
| 72 | + | require.Equal(t, 403, response.Code) | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | require.Equal(t, 204, response.Code) | |
| 76 | + | cookies := response.Result().Cookies() | |
| 77 | + | require.Len(t, cookies, 1) | |
| 78 | + | require.True(t, cookies[0].HttpOnly) | |
| 79 | + | require.Equal(t, http.SameSiteStrictMode, cookies[0].SameSite) | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestWebSocketRoundTripAndDetach(t *testing.T) { | |
| 85 | + | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) | |
| 86 | + | defer cancel() | |
| 87 | + | exited := make(chan struct{}) | |
| 88 | + | token := strings.Repeat("c", 43) | |
| 89 | + | handler, err := NewServer(ctx, Settings{}, token, func(ctx context.Context, terminal *Terminal) error { | |
| 90 | + | defer close(exited) | |
| 91 | + | if _, err := terminal.EnterRaw(); err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | data := make([]byte, 1) | |
| 95 | + | if _, err := io.ReadFull(terminal.In(), data); err != nil { | |
| 96 | + | return err | |
| 97 | + | } | |
| 98 | + | if _, err := terminal.Write(data); err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | if err := terminal.Flush(); err != nil { | |
| 102 | + | return err | |
| 103 | + | } | |
| 104 | + | <-ctx.Done() | |
| 105 | + | return ctx.Err() | |
| 106 | + | }) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Host = Address; handler.ServeHTTP(w, r) })) | |
| 109 | + | defer server.Close() | |
| 110 | + | headers := http.Header{"Origin": {Origin}, "Cookie": {cookieName + "=" + token}} | |
| 111 | + | conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http")+"/ws", &websocket.DialOptions{HTTPHeader: headers}) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | defer conn.CloseNow() | |
| 114 | + | conn.SetReadLimit(64 << 20) | |
| 115 | + | _, data, err := conn.Read(ctx) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | require.True(t, json.Valid(data)) | |
| 118 | + | require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(`{"schemaVersion":1,"type":"text","text":"Z"}`))) | |
| 119 | + | for { | |
| 120 | + | _, data, err = conn.Read(ctx) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | if strings.Contains(string(data), `"text":"Z"`) { | |
| 123 | + | break | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | require.NoError(t, conn.CloseNow()) | |
| 127 | + | select { | |
| 128 | + | case <-exited: | |
| 129 | + | case <-ctx.Done(): | |
| 130 | + | t.Fatal("attachment did not detach") | |
| 131 | + | } | |
| 132 | + | handler.Wait() | |
| 133 | + | } | |
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "io" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "net/url" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/coder/websocket" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestHTTPTrustBoundary(t *testing.T) { | |||
| 19 | + | token := strings.Repeat("a", 43) | |||
| 20 | + | server, err := NewServer(t.Context(), Settings{}, token, func(context.Context, *Terminal) error { return nil }) | |||
| 21 | + | require.NoError(t, err) | |||
| 22 | + | tests := []struct { | |||
| 23 | + | name, method, path, host, origin, site, credential string | |||
| 24 | + | want int | |||
| 25 | + | }{ | |||
| 26 | + | {"shell", "GET", "/", Address, "", "", "", 200}, | |||
| 27 | + | {"no auth", "GET", "/health", Address, "", "", "", 401}, | |||
| 28 | + | {"authenticated", "GET", "/health", Address, "", "", token, 204}, | |||
| 29 | + | {"rebinding", "GET", "/health", "attacker.invalid", "", "", token, 403}, | |||
| 30 | + | {"cross origin", "GET", "/health", Address, "https://attacker.invalid", "", token, 403}, | |||
| 31 | + | {"cross site", "GET", "/", Address, "", "cross-site", "", 403}, | |||
| 32 | + | {"ws requires origin", "GET", "/ws", Address, "", "", token, 403}, | |||
| 33 | + | {"health is read only", "POST", "/health", Address, Origin, "", token, 405}, | |||
| 34 | + | {"license is read only", "POST", "/icons-license", Address, "", "", "", 405}, | |||
| 35 | + | {"uppercase host", "GET", "/health", strings.ToUpper(Address), "", "", token, 204}, | |||
| 36 | + | {"trailing dot host", "GET", "/health", "127.0.0.1.:8778", "", "", token, 204}, | |||
| 37 | + | } | |||
| 38 | + | for _, tt := range tests { | |||
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 40 | + | request := httptest.NewRequest(tt.method, Origin+tt.path, nil) | |||
| 41 | + | request.Host = tt.host | |||
| 42 | + | request.Header.Set("Origin", tt.origin) | |||
| 43 | + | request.Header.Set("Sec-Fetch-Site", tt.site) | |||
| 44 | + | if tt.credential != "" { | |||
| 45 | + | request.AddCookie(&http.Cookie{Name: cookieName, Value: tt.credential}) | |||
| 46 | + | } | |||
| 47 | + | response := httptest.NewRecorder() | |||
| 48 | + | server.ServeHTTP(response, request) | |||
| 49 | + | require.Equal(t, tt.want, response.Code) | |||
| 50 | + | require.Contains(t, response.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'") | |||
| 51 | + | require.NotContains(t, response.Body.String(), token) | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestLogin(t *testing.T) { | |||
| 57 | + | token := strings.Repeat("b", 43) | |||
| 58 | + | server, err := NewServer(t.Context(), Settings{}, token, func(context.Context, *Terminal) error { return nil }) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | for _, valid := range []bool{false, true} { | |||
| 61 | + | t.Run(map[bool]string{false: "invalid", true: "valid"}[valid], func(t *testing.T) { | |||
| 62 | + | supplied := "wrong" | |||
| 63 | + | if valid { | |||
| 64 | + | supplied = token | |||
| 65 | + | } | |||
| 66 | + | request := httptest.NewRequest("POST", Origin+"/login", strings.NewReader(url.Values{"token": {supplied}}.Encode())) | |||
| 67 | + | request.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |||
| 68 | + | request.Header.Set("Origin", Origin) | |||
| 69 | + | response := httptest.NewRecorder() | |||
| 70 | + | server.ServeHTTP(response, request) | |||
| 71 | + | if !valid { | |||
| 72 | + | require.Equal(t, 403, response.Code) | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | require.Equal(t, 204, response.Code) | |||
| 76 | + | cookies := response.Result().Cookies() | |||
| 77 | + | require.Len(t, cookies, 1) | |||
| 78 | + | require.True(t, cookies[0].HttpOnly) | |||
| 79 | + | require.Equal(t, http.SameSiteStrictMode, cookies[0].SameSite) | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestWebSocketRoundTripAndDetach(t *testing.T) { | |||
| 85 | + | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) | |||
| 86 | + | defer cancel() | |||
| 87 | + | exited := make(chan struct{}) | |||
| 88 | + | token := strings.Repeat("c", 43) | |||
| 89 | + | handler, err := NewServer(ctx, Settings{}, token, func(ctx context.Context, terminal *Terminal) error { | |||
| 90 | + | defer close(exited) | |||
| 91 | + | if _, err := terminal.EnterRaw(); err != nil { | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 94 | + | data := make([]byte, 1) | |||
| 95 | + | if _, err := io.ReadFull(terminal.In(), data); err != nil { | |||
| 96 | + | return err | |||
| 97 | + | } | |||
| 98 | + | if _, err := terminal.Write(data); err != nil { | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 101 | + | if err := terminal.Flush(); err != nil { | |||
| 102 | + | return err | |||
| 103 | + | } | |||
| 104 | + | <-ctx.Done() | |||
| 105 | + | return ctx.Err() | |||
| 106 | + | }) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Host = Address; handler.ServeHTTP(w, r) })) | |||
| 109 | + | defer server.Close() | |||
| 110 | + | headers := http.Header{"Origin": {Origin}, "Cookie": {cookieName + "=" + token}} | |||
| 111 | + | conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http")+"/ws", &websocket.DialOptions{HTTPHeader: headers}) | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | defer conn.CloseNow() | |||
| 114 | + | conn.SetReadLimit(64 << 20) | |||
| 115 | + | _, data, err := conn.Read(ctx) | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | require.True(t, json.Valid(data)) | |||
| 118 | + | require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(`{"schemaVersion":1,"type":"text","text":"Z"}`))) | |||
| 119 | + | for { | |||
| 120 | + | _, data, err = conn.Read(ctx) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | if strings.Contains(string(data), `"text":"Z"`) { | |||
| 123 | + | break | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | require.NoError(t, conn.CloseNow()) | |||
| 127 | + | select { | |||
| 128 | + | case <-exited: | |||
| 129 | + | case <-ctx.Done(): | |||
| 130 | + | t.Fatal("attachment did not detach") | |||
| 131 | + | } | |||
| 132 | + | handler.Wait() | |||
| 133 | + | } | |||
A internal/adapters/webterm/settings.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "net" | |
| 6 | + | "net/netip" | |
| 7 | + | "net/url" | |
| 8 | + | "strconv" | |
| 9 | + | "strings" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Settings separates the local HTTP listener from the browser-facing origin. | |
| 13 | + | // HTTPS, when selected, is terminated by a proxy preserving Host and Origin. | |
| 14 | + | type Settings struct { | |
| 15 | + | Listen string `json:"listen"` | |
| 16 | + | Origin string `json:"origin"` | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func ParseSettings(listen, origin string) (Settings, error) { | |
| 20 | + | if listen == "" { | |
| 21 | + | listen = Address | |
| 22 | + | } | |
| 23 | + | addr, err := netip.ParseAddrPort(listen) | |
| 24 | + | if err != nil || addr.Port() == 0 || addr.Addr().Zone() != "" || addr.Addr().IsMulticast() { | |
| 25 | + | return Settings{}, errors.New("webterm: web.listen requires an IP address and port (1–65535)") | |
| 26 | + | } | |
| 27 | + | listen = addr.String() | |
| 28 | + | if origin == "" { | |
| 29 | + | if !addr.Addr().IsLoopback() { | |
| 30 | + | return Settings{}, errors.New("webterm: web.origin is required for a non-loopback listener") | |
| 31 | + | } | |
| 32 | + | origin = "http://" + listen | |
| 33 | + | } | |
| 34 | + | u, err := url.Parse(origin) | |
| 35 | + | if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(origin, "#") { | |
| 36 | + | return Settings{}, errors.New("webterm: web.origin requires an HTTP(S) origin without credentials, path, query or fragment") | |
| 37 | + | } | |
| 38 | + | host := strings.ToLower(u.Hostname()) | |
| 39 | + | if ip, err := netip.ParseAddr(host); err == nil { | |
| 40 | + | if ip.IsUnspecified() || ip.IsMulticast() || ip.Zone() != "" { | |
| 41 | + | return Settings{}, errors.New("webterm: web.origin requires a concrete host") | |
| 42 | + | } | |
| 43 | + | host = ip.String() | |
| 44 | + | } else { | |
| 45 | + | if len(host) > 253 { | |
| 46 | + | return Settings{}, errors.New("webterm: invalid web.origin hostname") | |
| 47 | + | } | |
| 48 | + | for _, label := range strings.Split(host, ".") { | |
| 49 | + | if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { | |
| 50 | + | return Settings{}, errors.New("webterm: invalid web.origin hostname") | |
| 51 | + | } | |
| 52 | + | for _, c := range label { | |
| 53 | + | if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { | |
| 54 | + | return Settings{}, errors.New("webterm: invalid web.origin hostname") | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | port := u.Port() | |
| 60 | + | if port != "" { | |
| 61 | + | n, err := strconv.Atoi(port) | |
| 62 | + | if err != nil || n < 1 || n > 65535 { | |
| 63 | + | return Settings{}, errors.New("webterm: invalid web.origin port") | |
| 64 | + | } | |
| 65 | + | port = strconv.Itoa(n) | |
| 66 | + | if u.Scheme == "http" && port == "80" || u.Scheme == "https" && port == "443" { | |
| 67 | + | port = "" | |
| 68 | + | } | |
| 69 | + | } else if strings.HasSuffix(u.Host, ":") { | |
| 70 | + | return Settings{}, errors.New("webterm: invalid web.origin port") | |
| 71 | + | } | |
| 72 | + | if port != "" { | |
| 73 | + | host = net.JoinHostPort(host, port) | |
| 74 | + | } else if strings.Contains(host, ":") { | |
| 75 | + | host = "[" + host + "]" | |
| 76 | + | } | |
| 77 | + | return Settings{Listen: listen, Origin: u.Scheme + "://" + host}, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // ProbeAddress never resolves or contacts the public origin. Wildcard listeners | |
| 81 | + | // are reached through the matching loopback family on this machine. | |
| 82 | + | // A zero-value Settings yields an empty address instead of panicking. | |
| 83 | + | func (s Settings) ProbeAddress() string { | |
| 84 | + | addr, err := netip.ParseAddrPort(s.Listen) | |
| 85 | + | if err != nil { | |
| 86 | + | return "" | |
| 87 | + | } | |
| 88 | + | if addr.Addr().IsUnspecified() { | |
| 89 | + | ip := netip.IPv6Loopback() | |
| 90 | + | if addr.Addr().Is4() { | |
| 91 | + | ip = netip.MustParseAddr("127.0.0.1") | |
| 92 | + | } | |
| 93 | + | addr = netip.AddrPortFrom(ip, addr.Port()) | |
| 94 | + | } | |
| 95 | + | return addr.String() | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (s Settings) Host() string { | |
| 99 | + | _, host, _ := strings.Cut(s.Origin, "://") | |
| 100 | + | return host | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "net" | |||
| 6 | + | "net/netip" | |||
| 7 | + | "net/url" | |||
| 8 | + | "strconv" | |||
| 9 | + | "strings" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Settings separates the local HTTP listener from the browser-facing origin. | |||
| 13 | + | // HTTPS, when selected, is terminated by a proxy preserving Host and Origin. | |||
| 14 | + | type Settings struct { | |||
| 15 | + | Listen string `json:"listen"` | |||
| 16 | + | Origin string `json:"origin"` | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func ParseSettings(listen, origin string) (Settings, error) { | |||
| 20 | + | if listen == "" { | |||
| 21 | + | listen = Address | |||
| 22 | + | } | |||
| 23 | + | addr, err := netip.ParseAddrPort(listen) | |||
| 24 | + | if err != nil || addr.Port() == 0 || addr.Addr().Zone() != "" || addr.Addr().IsMulticast() { | |||
| 25 | + | return Settings{}, errors.New("webterm: web.listen requires an IP address and port (1–65535)") | |||
| 26 | + | } | |||
| 27 | + | listen = addr.String() | |||
| 28 | + | if origin == "" { | |||
| 29 | + | if !addr.Addr().IsLoopback() { | |||
| 30 | + | return Settings{}, errors.New("webterm: web.origin is required for a non-loopback listener") | |||
| 31 | + | } | |||
| 32 | + | origin = "http://" + listen | |||
| 33 | + | } | |||
| 34 | + | u, err := url.Parse(origin) | |||
| 35 | + | if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(origin, "#") { | |||
| 36 | + | return Settings{}, errors.New("webterm: web.origin requires an HTTP(S) origin without credentials, path, query or fragment") | |||
| 37 | + | } | |||
| 38 | + | host := strings.ToLower(u.Hostname()) | |||
| 39 | + | if ip, err := netip.ParseAddr(host); err == nil { | |||
| 40 | + | if ip.IsUnspecified() || ip.IsMulticast() || ip.Zone() != "" { | |||
| 41 | + | return Settings{}, errors.New("webterm: web.origin requires a concrete host") | |||
| 42 | + | } | |||
| 43 | + | host = ip.String() | |||
| 44 | + | } else { | |||
| 45 | + | if len(host) > 253 { | |||
| 46 | + | return Settings{}, errors.New("webterm: invalid web.origin hostname") | |||
| 47 | + | } | |||
| 48 | + | for _, label := range strings.Split(host, ".") { | |||
| 49 | + | if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { | |||
| 50 | + | return Settings{}, errors.New("webterm: invalid web.origin hostname") | |||
| 51 | + | } | |||
| 52 | + | for _, c := range label { | |||
| 53 | + | if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { | |||
| 54 | + | return Settings{}, errors.New("webterm: invalid web.origin hostname") | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | port := u.Port() | |||
| 60 | + | if port != "" { | |||
| 61 | + | n, err := strconv.Atoi(port) | |||
| 62 | + | if err != nil || n < 1 || n > 65535 { | |||
| 63 | + | return Settings{}, errors.New("webterm: invalid web.origin port") | |||
| 64 | + | } | |||
| 65 | + | port = strconv.Itoa(n) | |||
| 66 | + | if u.Scheme == "http" && port == "80" || u.Scheme == "https" && port == "443" { | |||
| 67 | + | port = "" | |||
| 68 | + | } | |||
| 69 | + | } else if strings.HasSuffix(u.Host, ":") { | |||
| 70 | + | return Settings{}, errors.New("webterm: invalid web.origin port") | |||
| 71 | + | } | |||
| 72 | + | if port != "" { | |||
| 73 | + | host = net.JoinHostPort(host, port) | |||
| 74 | + | } else if strings.Contains(host, ":") { | |||
| 75 | + | host = "[" + host + "]" | |||
| 76 | + | } | |||
| 77 | + | return Settings{Listen: listen, Origin: u.Scheme + "://" + host}, nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // ProbeAddress never resolves or contacts the public origin. Wildcard listeners | |||
| 81 | + | // are reached through the matching loopback family on this machine. | |||
| 82 | + | // A zero-value Settings yields an empty address instead of panicking. | |||
| 83 | + | func (s Settings) ProbeAddress() string { | |||
| 84 | + | addr, err := netip.ParseAddrPort(s.Listen) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return "" | |||
| 87 | + | } | |||
| 88 | + | if addr.Addr().IsUnspecified() { | |||
| 89 | + | ip := netip.IPv6Loopback() | |||
| 90 | + | if addr.Addr().Is4() { | |||
| 91 | + | ip = netip.MustParseAddr("127.0.0.1") | |||
| 92 | + | } | |||
| 93 | + | addr = netip.AddrPortFrom(ip, addr.Port()) | |||
| 94 | + | } | |||
| 95 | + | return addr.String() | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (s Settings) Host() string { | |||
| 99 | + | _, host, _ := strings.Cut(s.Origin, "://") | |||
| 100 | + | return host | |||
| 101 | + | } | |||
A internal/adapters/webterm/settings_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestWebSettings(t *testing.T) { | |
| 10 | + | for _, tt := range []struct { | |
| 11 | + | name, listen, origin, wantListen, wantOrigin string | |
| 12 | + | }{ | |
| 13 | + | {"defaults", "", "", Address, Origin}, | |
| 14 | + | {"loopback port", "127.0.0.1:9000", "", "127.0.0.1:9000", "http://127.0.0.1:9000"}, | |
| 15 | + | {"IPv6", "[::1]:9000", "", "[::1]:9000", "http://[::1]:9000"}, | |
| 16 | + | {"proxy", "", "https://Terminal.example.internal:443/", Address, "https://terminal.example.internal"}, | |
| 17 | + | {"private HTTP", "100.64.0.10:8778", "http://100.64.0.10:8778", "100.64.0.10:8778", "http://100.64.0.10:8778"}, | |
| 18 | + | {"wildcard explicit", "0.0.0.0:9000", "https://terminal.example.internal", "0.0.0.0:9000", "https://terminal.example.internal"}, | |
| 19 | + | {"IPv6 proxy", "[::]:9000", "https://[fd00::1]:9443", "[::]:9000", "https://[fd00::1]:9443"}, | |
| 20 | + | } { | |
| 21 | + | t.Run(tt.name, func(t *testing.T) { | |
| 22 | + | got, err := ParseSettings(tt.listen, tt.origin) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | require.Equal(t, Settings{Listen: tt.wantListen, Origin: tt.wantOrigin}, got) | |
| 25 | + | }) | |
| 26 | + | } | |
| 27 | + | for _, listen := range []string{"localhost:8778", ":8778", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:http", "[fe80::1%eth0]:8778", "224.0.0.1:8778"} { | |
| 28 | + | t.Run(listen, func(t *testing.T) { | |
| 29 | + | _, err := ParseSettings(listen, Origin) | |
| 30 | + | require.Error(t, err) | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | for _, origin := range []string{"//example.internal", "ftp://example.internal", "https://user@example.internal", "https://example.internal/path", "https://example.internal?", "https://example.internal#", "https://example.internal:0", "https://example.internal:65536", "https://example.internal:", "https://*.example.internal", "https://0.0.0.0", "https://[::]", "https://bad_host", "https://example.internal/%2f", "https://[fe80::1%25eth0]"} { | |
| 34 | + | t.Run(origin, func(t *testing.T) { | |
| 35 | + | _, err := ParseSettings("", origin) | |
| 36 | + | require.Error(t, err) | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | for _, listen := range []string{"0.0.0.0:8778", "[::]:8778", "100.64.0.10:8778"} { | |
| 40 | + | _, err := ParseSettings(listen, "") | |
| 41 | + | require.Error(t, err) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestWebProbeAddressZeroValue(t *testing.T) { | |
| 46 | + | var settings Settings | |
| 47 | + | require.Equal(t, "", settings.ProbeAddress(), "zero-value settings must not panic") | |
| 48 | + | settings.Listen = "not-an-address" | |
| 49 | + | require.Equal(t, "", settings.ProbeAddress()) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestWebProbeAddress(t *testing.T) { | |
| 53 | + | for _, tt := range []struct{ listen, want string }{ | |
| 54 | + | {"0.0.0.0:9000", "127.0.0.1:9000"}, | |
| 55 | + | {"[::]:9000", "[::1]:9000"}, | |
| 56 | + | {"127.0.0.2:9000", "127.0.0.2:9000"}, | |
| 57 | + | } { | |
| 58 | + | t.Run(tt.listen, func(t *testing.T) { | |
| 59 | + | settings, err := ParseSettings(tt.listen, "https://terminal.example.internal") | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.Equal(t, tt.want, settings.ProbeAddress()) | |
| 62 | + | }) | |
| 63 | + | } | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestWebSettings(t *testing.T) { | |||
| 10 | + | for _, tt := range []struct { | |||
| 11 | + | name, listen, origin, wantListen, wantOrigin string | |||
| 12 | + | }{ | |||
| 13 | + | {"defaults", "", "", Address, Origin}, | |||
| 14 | + | {"loopback port", "127.0.0.1:9000", "", "127.0.0.1:9000", "http://127.0.0.1:9000"}, | |||
| 15 | + | {"IPv6", "[::1]:9000", "", "[::1]:9000", "http://[::1]:9000"}, | |||
| 16 | + | {"proxy", "", "https://Terminal.example.internal:443/", Address, "https://terminal.example.internal"}, | |||
| 17 | + | {"private HTTP", "100.64.0.10:8778", "http://100.64.0.10:8778", "100.64.0.10:8778", "http://100.64.0.10:8778"}, | |||
| 18 | + | {"wildcard explicit", "0.0.0.0:9000", "https://terminal.example.internal", "0.0.0.0:9000", "https://terminal.example.internal"}, | |||
| 19 | + | {"IPv6 proxy", "[::]:9000", "https://[fd00::1]:9443", "[::]:9000", "https://[fd00::1]:9443"}, | |||
| 20 | + | } { | |||
| 21 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 22 | + | got, err := ParseSettings(tt.listen, tt.origin) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | require.Equal(t, Settings{Listen: tt.wantListen, Origin: tt.wantOrigin}, got) | |||
| 25 | + | }) | |||
| 26 | + | } | |||
| 27 | + | for _, listen := range []string{"localhost:8778", ":8778", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:http", "[fe80::1%eth0]:8778", "224.0.0.1:8778"} { | |||
| 28 | + | t.Run(listen, func(t *testing.T) { | |||
| 29 | + | _, err := ParseSettings(listen, Origin) | |||
| 30 | + | require.Error(t, err) | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | for _, origin := range []string{"//example.internal", "ftp://example.internal", "https://user@example.internal", "https://example.internal/path", "https://example.internal?", "https://example.internal#", "https://example.internal:0", "https://example.internal:65536", "https://example.internal:", "https://*.example.internal", "https://0.0.0.0", "https://[::]", "https://bad_host", "https://example.internal/%2f", "https://[fe80::1%25eth0]"} { | |||
| 34 | + | t.Run(origin, func(t *testing.T) { | |||
| 35 | + | _, err := ParseSettings("", origin) | |||
| 36 | + | require.Error(t, err) | |||
| 37 | + | }) | |||
| 38 | + | } | |||
| 39 | + | for _, listen := range []string{"0.0.0.0:8778", "[::]:8778", "100.64.0.10:8778"} { | |||
| 40 | + | _, err := ParseSettings(listen, "") | |||
| 41 | + | require.Error(t, err) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestWebProbeAddressZeroValue(t *testing.T) { | |||
| 46 | + | var settings Settings | |||
| 47 | + | require.Equal(t, "", settings.ProbeAddress(), "zero-value settings must not panic") | |||
| 48 | + | settings.Listen = "not-an-address" | |||
| 49 | + | require.Equal(t, "", settings.ProbeAddress()) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestWebProbeAddress(t *testing.T) { | |||
| 53 | + | for _, tt := range []struct{ listen, want string }{ | |||
| 54 | + | {"0.0.0.0:9000", "127.0.0.1:9000"}, | |||
| 55 | + | {"[::]:9000", "[::1]:9000"}, | |||
| 56 | + | {"127.0.0.2:9000", "127.0.0.2:9000"}, | |||
| 57 | + | } { | |||
| 58 | + | t.Run(tt.listen, func(t *testing.T) { | |||
| 59 | + | settings, err := ParseSettings(tt.listen, "https://terminal.example.internal") | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | require.Equal(t, tt.want, settings.ProbeAddress()) | |||
| 62 | + | }) | |||
| 63 | + | } | |||
| 64 | + | } | |||
A internal/adapters/webterm/shutdown_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestShutdownAdmissionRace(t *testing.T) { | |
| 13 | + | for range 100 { | |
| 14 | + | server, err := NewServer(t.Context(), Settings{}, strings.Repeat("d", 43), func(context.Context, *Terminal) error { return nil }) | |
| 15 | + | require.NoError(t, err) | |
| 16 | + | var racers sync.WaitGroup | |
| 17 | + | start := make(chan struct{}) | |
| 18 | + | admitted := make(chan bool, 1) | |
| 19 | + | release := make(chan struct{}) | |
| 20 | + | drained := make(chan struct{}) | |
| 21 | + | racers.Add(2) | |
| 22 | + | go func() { | |
| 23 | + | defer racers.Done() | |
| 24 | + | <-start | |
| 25 | + | ok := server.admit() | |
| 26 | + | admitted <- ok | |
| 27 | + | if ok { | |
| 28 | + | <-release | |
| 29 | + | server.workers.Done() | |
| 30 | + | } | |
| 31 | + | }() | |
| 32 | + | go func() { defer racers.Done(); <-start; server.Wait(); close(drained) }() | |
| 33 | + | close(start) | |
| 34 | + | if <-admitted { | |
| 35 | + | select { | |
| 36 | + | case <-drained: | |
| 37 | + | t.Fatal("shutdown missed an admitted worker") | |
| 38 | + | default: | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | close(release) | |
| 42 | + | racers.Wait() | |
| 43 | + | require.False(t, server.admit(), "shutdown must permanently close admission") | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestCanceledServerRejectsAdmission(t *testing.T) { | |
| 48 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 49 | + | server, err := NewServer(ctx, Settings{}, strings.Repeat("e", 43), func(context.Context, *Terminal) error { return nil }) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | cancel() | |
| 52 | + | require.False(t, server.admit()) | |
| 53 | + | server.Wait() | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestShutdownAdmissionRace(t *testing.T) { | |||
| 13 | + | for range 100 { | |||
| 14 | + | server, err := NewServer(t.Context(), Settings{}, strings.Repeat("d", 43), func(context.Context, *Terminal) error { return nil }) | |||
| 15 | + | require.NoError(t, err) | |||
| 16 | + | var racers sync.WaitGroup | |||
| 17 | + | start := make(chan struct{}) | |||
| 18 | + | admitted := make(chan bool, 1) | |||
| 19 | + | release := make(chan struct{}) | |||
| 20 | + | drained := make(chan struct{}) | |||
| 21 | + | racers.Add(2) | |||
| 22 | + | go func() { | |||
| 23 | + | defer racers.Done() | |||
| 24 | + | <-start | |||
| 25 | + | ok := server.admit() | |||
| 26 | + | admitted <- ok | |||
| 27 | + | if ok { | |||
| 28 | + | <-release | |||
| 29 | + | server.workers.Done() | |||
| 30 | + | } | |||
| 31 | + | }() | |||
| 32 | + | go func() { defer racers.Done(); <-start; server.Wait(); close(drained) }() | |||
| 33 | + | close(start) | |||
| 34 | + | if <-admitted { | |||
| 35 | + | select { | |||
| 36 | + | case <-drained: | |||
| 37 | + | t.Fatal("shutdown missed an admitted worker") | |||
| 38 | + | default: | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | close(release) | |||
| 42 | + | racers.Wait() | |||
| 43 | + | require.False(t, server.admit(), "shutdown must permanently close admission") | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestCanceledServerRejectsAdmission(t *testing.T) { | |||
| 48 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 49 | + | server, err := NewServer(ctx, Settings{}, strings.Repeat("e", 43), func(context.Context, *Terminal) error { return nil }) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | cancel() | |||
| 52 | + | require.False(t, server.admit()) | |||
| 53 | + | server.Wait() | |||
| 54 | + | } | |||
A internal/adapters/webterm/terminal.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | // Package webterm connects the ordinary client terminal boundary to a browser. | |
| 2 | + | package webterm | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "sync" | |
| 9 | + | ||
| 10 | + | vt "github.com/bnema/vev-vt" | |
| 11 | + | renderer "github.com/bnema/vev-vt/core" | |
| 12 | + | "github.com/bnema/vev/internal/adapters/term" | |
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | MaxColumns = 512 | |
| 19 | + | MaxRows = 256 | |
| 20 | + | inputQueueDepth = 64 | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // Terminal owns a VT mirror of successfully flushed client output. Input and | |
| 24 | + | // terminal query replies share one ordered writer, just like a physical TTY. | |
| 25 | + | type Terminal struct { | |
| 26 | + | mu sync.Mutex | |
| 27 | + | wheelAcc float64 | |
| 28 | + | screen *vt.Screen | |
| 29 | + | geometry domain.Geometry | |
| 30 | + | latest vt.ScreenSnapshot | |
| 31 | + | changes chan struct{} | |
| 32 | + | resize chan domain.Geometry | |
| 33 | + | input chan []byte | |
| 34 | + | reader *io.PipeReader | |
| 35 | + | writer *io.PipeWriter | |
| 36 | + | cancel context.CancelFunc | |
| 37 | + | done chan struct{} | |
| 38 | + | closed bool | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | var _ ports.Terminal = (*Terminal)(nil) | |
| 42 | + | ||
| 43 | + | func New(ctx context.Context, geometry domain.Geometry) (*Terminal, error) { | |
| 44 | + | if geometry.Cols < 1 || geometry.Rows < 1 || geometry.Cols > MaxColumns || geometry.Rows > MaxRows { | |
| 45 | + | return nil, errors.New("webterm: invalid geometry") | |
| 46 | + | } | |
| 47 | + | ctx, cancel := context.WithCancel(ctx) | |
| 48 | + | reader, writer := io.Pipe() | |
| 49 | + | t := &Terminal{screen: vt.NewScreen(geometry.Cols, geometry.Rows), geometry: geometry, changes: make(chan struct{}, 1), resize: make(chan domain.Geometry, 1), input: make(chan []byte, inputQueueDepth), reader: reader, writer: writer, cancel: cancel, done: make(chan struct{})} | |
| 50 | + | t.screen.OnResponse = func(data []byte) { | |
| 51 | + | select { | |
| 52 | + | case t.input <- append([]byte(nil), data...): | |
| 53 | + | default: | |
| 54 | + | cancel() | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | // Match #terminal's default colors in app.css so the ordinary theme | |
| 58 | + | // discovery enables native inactive-pane and modal-backdrop dimming. | |
| 59 | + | t.screen.SetDefaultColors(renderer.RGB{R: 216, G: 216, B: 216}, renderer.RGB{R: 16, G: 16, B: 16}, true) | |
| 60 | + | t.latest = t.screen.Snapshot() | |
| 61 | + | go func() { | |
| 62 | + | defer close(t.done) | |
| 63 | + | stop := context.AfterFunc(ctx, func() { _ = writer.CloseWithError(ctx.Err()); _ = reader.CloseWithError(ctx.Err()) }) | |
| 64 | + | defer stop() | |
| 65 | + | for { | |
| 66 | + | select { | |
| 67 | + | case <-ctx.Done(): | |
| 68 | + | return | |
| 69 | + | case data := <-t.input: | |
| 70 | + | if _, err := writer.Write(data); err != nil { | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | }() | |
| 76 | + | return t, nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (t *Terminal) EnterRaw() (func() error, error) { | |
| 80 | + | if _, err := t.Write(term.VisualEnterSequence()); err != nil { | |
| 81 | + | return nil, err | |
| 82 | + | } | |
| 83 | + | if err := t.Flush(); err != nil { | |
| 84 | + | return nil, err | |
| 85 | + | } | |
| 86 | + | return func() error { return nil }, nil | |
| 87 | + | } | |
| 88 | + | func (t *Terminal) In() io.Reader { return t.reader } | |
| 89 | + | func (t *Terminal) Out() io.Writer { return t } | |
| 90 | + | func (t *Terminal) ResizeEvents() <-chan domain.Geometry { return t.resize } | |
| 91 | + | func (t *Terminal) Changes() <-chan struct{} { return t.changes } | |
| 92 | + | func (t *Terminal) Geometry() (domain.Geometry, error) { | |
| 93 | + | t.mu.Lock() | |
| 94 | + | defer t.mu.Unlock() | |
| 95 | + | if t.closed { | |
| 96 | + | return domain.Geometry{}, io.ErrClosedPipe | |
| 97 | + | } | |
| 98 | + | return t.geometry, nil | |
| 99 | + | } | |
| 100 | + | func (t *Terminal) Write(data []byte) (int, error) { | |
| 101 | + | t.mu.Lock() | |
| 102 | + | defer t.mu.Unlock() | |
| 103 | + | if t.closed { | |
| 104 | + | return 0, io.ErrClosedPipe | |
| 105 | + | } | |
| 106 | + | t.screen.Write(data) | |
| 107 | + | return len(data), nil | |
| 108 | + | } | |
| 109 | + | func (t *Terminal) Flush() error { | |
| 110 | + | t.mu.Lock() | |
| 111 | + | defer t.mu.Unlock() | |
| 112 | + | if t.closed { | |
| 113 | + | return io.ErrClosedPipe | |
| 114 | + | } | |
| 115 | + | t.latest = t.screen.Snapshot() | |
| 116 | + | select { | |
| 117 | + | case t.changes <- struct{}{}: | |
| 118 | + | default: | |
| 119 | + | } | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | func (t *Terminal) Snapshot() vt.ScreenSnapshot { | |
| 123 | + | t.mu.Lock() | |
| 124 | + | defer t.mu.Unlock() | |
| 125 | + | return t.latest | |
| 126 | + | } | |
| 127 | + | func (t *Terminal) Resize(geometry domain.Geometry) error { | |
| 128 | + | if geometry.Cols < 1 || geometry.Rows < 1 || geometry.Cols > MaxColumns || geometry.Rows > MaxRows { | |
| 129 | + | return errors.New("webterm: invalid geometry") | |
| 130 | + | } | |
| 131 | + | t.mu.Lock() | |
| 132 | + | defer t.mu.Unlock() | |
| 133 | + | if t.closed { | |
| 134 | + | return io.ErrClosedPipe | |
| 135 | + | } | |
| 136 | + | if t.geometry == geometry { | |
| 137 | + | return nil | |
| 138 | + | } | |
| 139 | + | t.geometry = geometry | |
| 140 | + | t.screen.SetGeometry(vt.Geometry{Cols: geometry.Cols, Rows: geometry.Rows, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}) | |
| 141 | + | select { | |
| 142 | + | case <-t.resize: | |
| 143 | + | default: | |
| 144 | + | } | |
| 145 | + | t.resize <- geometry | |
| 146 | + | return nil | |
| 147 | + | } | |
| 148 | + | func (t *Terminal) Send(ctx context.Context, data []byte) error { | |
| 149 | + | if len(data) == 0 { | |
| 150 | + | return nil | |
| 151 | + | } | |
| 152 | + | select { | |
| 153 | + | case <-ctx.Done(): | |
| 154 | + | return ctx.Err() | |
| 155 | + | case <-t.done: | |
| 156 | + | return io.ErrClosedPipe | |
| 157 | + | case t.input <- append([]byte(nil), data...): | |
| 158 | + | return nil | |
| 159 | + | default: | |
| 160 | + | return errors.New("webterm: input queue full") | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | func (t *Terminal) Close() { | |
| 164 | + | t.mu.Lock() | |
| 165 | + | t.closed = true | |
| 166 | + | t.mu.Unlock() | |
| 167 | + | t.cancel() | |
| 168 | + | _ = t.reader.Close() | |
| 169 | + | _ = t.writer.Close() | |
| 170 | + | <-t.done | |
| 171 | + | } | |
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | // Package webterm connects the ordinary client terminal boundary to a browser. | |||
| 2 | + | package webterm | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 8 | + | "sync" | |||
| 9 | + | ||||
| 10 | + | vt "github.com/bnema/vev-vt" | |||
| 11 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 12 | + | "github.com/bnema/vev/internal/adapters/term" | |||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | MaxColumns = 512 | |||
| 19 | + | MaxRows = 256 | |||
| 20 | + | inputQueueDepth = 64 | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // Terminal owns a VT mirror of successfully flushed client output. Input and | |||
| 24 | + | // terminal query replies share one ordered writer, just like a physical TTY. | |||
| 25 | + | type Terminal struct { | |||
| 26 | + | mu sync.Mutex | |||
| 27 | + | wheelAcc float64 | |||
| 28 | + | screen *vt.Screen | |||
| 29 | + | geometry domain.Geometry | |||
| 30 | + | latest vt.ScreenSnapshot | |||
| 31 | + | changes chan struct{} | |||
| 32 | + | resize chan domain.Geometry | |||
| 33 | + | input chan []byte | |||
| 34 | + | reader *io.PipeReader | |||
| 35 | + | writer *io.PipeWriter | |||
| 36 | + | cancel context.CancelFunc | |||
| 37 | + | done chan struct{} | |||
| 38 | + | closed bool | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | var _ ports.Terminal = (*Terminal)(nil) | |||
| 42 | + | ||||
| 43 | + | func New(ctx context.Context, geometry domain.Geometry) (*Terminal, error) { | |||
| 44 | + | if geometry.Cols < 1 || geometry.Rows < 1 || geometry.Cols > MaxColumns || geometry.Rows > MaxRows { | |||
| 45 | + | return nil, errors.New("webterm: invalid geometry") | |||
| 46 | + | } | |||
| 47 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 48 | + | reader, writer := io.Pipe() | |||
| 49 | + | t := &Terminal{screen: vt.NewScreen(geometry.Cols, geometry.Rows), geometry: geometry, changes: make(chan struct{}, 1), resize: make(chan domain.Geometry, 1), input: make(chan []byte, inputQueueDepth), reader: reader, writer: writer, cancel: cancel, done: make(chan struct{})} | |||
| 50 | + | t.screen.OnResponse = func(data []byte) { | |||
| 51 | + | select { | |||
| 52 | + | case t.input <- append([]byte(nil), data...): | |||
| 53 | + | default: | |||
| 54 | + | cancel() | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | // Match #terminal's default colors in app.css so the ordinary theme | |||
| 58 | + | // discovery enables native inactive-pane and modal-backdrop dimming. | |||
| 59 | + | t.screen.SetDefaultColors(renderer.RGB{R: 216, G: 216, B: 216}, renderer.RGB{R: 16, G: 16, B: 16}, true) | |||
| 60 | + | t.latest = t.screen.Snapshot() | |||
| 61 | + | go func() { | |||
| 62 | + | defer close(t.done) | |||
| 63 | + | stop := context.AfterFunc(ctx, func() { _ = writer.CloseWithError(ctx.Err()); _ = reader.CloseWithError(ctx.Err()) }) | |||
| 64 | + | defer stop() | |||
| 65 | + | for { | |||
| 66 | + | select { | |||
| 67 | + | case <-ctx.Done(): | |||
| 68 | + | return | |||
| 69 | + | case data := <-t.input: | |||
| 70 | + | if _, err := writer.Write(data); err != nil { | |||
| 71 | + | return | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | }() | |||
| 76 | + | return t, nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 80 | + | if _, err := t.Write(term.VisualEnterSequence()); err != nil { | |||
| 81 | + | return nil, err | |||
| 82 | + | } | |||
| 83 | + | if err := t.Flush(); err != nil { | |||
| 84 | + | return nil, err | |||
| 85 | + | } | |||
| 86 | + | return func() error { return nil }, nil | |||
| 87 | + | } | |||
| 88 | + | func (t *Terminal) In() io.Reader { return t.reader } | |||
| 89 | + | func (t *Terminal) Out() io.Writer { return t } | |||
| 90 | + | func (t *Terminal) ResizeEvents() <-chan domain.Geometry { return t.resize } | |||
| 91 | + | func (t *Terminal) Changes() <-chan struct{} { return t.changes } | |||
| 92 | + | func (t *Terminal) Geometry() (domain.Geometry, error) { | |||
| 93 | + | t.mu.Lock() | |||
| 94 | + | defer t.mu.Unlock() | |||
| 95 | + | if t.closed { | |||
| 96 | + | return domain.Geometry{}, io.ErrClosedPipe | |||
| 97 | + | } | |||
| 98 | + | return t.geometry, nil | |||
| 99 | + | } | |||
| 100 | + | func (t *Terminal) Write(data []byte) (int, error) { | |||
| 101 | + | t.mu.Lock() | |||
| 102 | + | defer t.mu.Unlock() | |||
| 103 | + | if t.closed { | |||
| 104 | + | return 0, io.ErrClosedPipe | |||
| 105 | + | } | |||
| 106 | + | t.screen.Write(data) | |||
| 107 | + | return len(data), nil | |||
| 108 | + | } | |||
| 109 | + | func (t *Terminal) Flush() error { | |||
| 110 | + | t.mu.Lock() | |||
| 111 | + | defer t.mu.Unlock() | |||
| 112 | + | if t.closed { | |||
| 113 | + | return io.ErrClosedPipe | |||
| 114 | + | } | |||
| 115 | + | t.latest = t.screen.Snapshot() | |||
| 116 | + | select { | |||
| 117 | + | case t.changes <- struct{}{}: | |||
| 118 | + | default: | |||
| 119 | + | } | |||
| 120 | + | return nil | |||
| 121 | + | } | |||
| 122 | + | func (t *Terminal) Snapshot() vt.ScreenSnapshot { | |||
| 123 | + | t.mu.Lock() | |||
| 124 | + | defer t.mu.Unlock() | |||
| 125 | + | return t.latest | |||
| 126 | + | } | |||
| 127 | + | func (t *Terminal) Resize(geometry domain.Geometry) error { | |||
| 128 | + | if geometry.Cols < 1 || geometry.Rows < 1 || geometry.Cols > MaxColumns || geometry.Rows > MaxRows { | |||
| 129 | + | return errors.New("webterm: invalid geometry") | |||
| 130 | + | } | |||
| 131 | + | t.mu.Lock() | |||
| 132 | + | defer t.mu.Unlock() | |||
| 133 | + | if t.closed { | |||
| 134 | + | return io.ErrClosedPipe | |||
| 135 | + | } | |||
| 136 | + | if t.geometry == geometry { | |||
| 137 | + | return nil | |||
| 138 | + | } | |||
| 139 | + | t.geometry = geometry | |||
| 140 | + | t.screen.SetGeometry(vt.Geometry{Cols: geometry.Cols, Rows: geometry.Rows, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}) | |||
| 141 | + | select { | |||
| 142 | + | case <-t.resize: | |||
| 143 | + | default: | |||
| 144 | + | } | |||
| 145 | + | t.resize <- geometry | |||
| 146 | + | return nil | |||
| 147 | + | } | |||
| 148 | + | func (t *Terminal) Send(ctx context.Context, data []byte) error { | |||
| 149 | + | if len(data) == 0 { | |||
| 150 | + | return nil | |||
| 151 | + | } | |||
| 152 | + | select { | |||
| 153 | + | case <-ctx.Done(): | |||
| 154 | + | return ctx.Err() | |||
| 155 | + | case <-t.done: | |||
| 156 | + | return io.ErrClosedPipe | |||
| 157 | + | case t.input <- append([]byte(nil), data...): | |||
| 158 | + | return nil | |||
| 159 | + | default: | |||
| 160 | + | return errors.New("webterm: input queue full") | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | func (t *Terminal) Close() { | |||
| 164 | + | t.mu.Lock() | |||
| 165 | + | t.closed = true | |||
| 166 | + | t.mu.Unlock() | |||
| 167 | + | t.cancel() | |||
| 168 | + | _ = t.reader.Close() | |||
| 169 | + | _ = t.writer.Close() | |||
| 170 | + | <-t.done | |||
| 171 | + | } | |||
A internal/adapters/webterm/terminal_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev-vt/html/browser" | |
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestTerminalFlushResizeAndClose(t *testing.T) { | |
| 15 | + | terminal, err := New(t.Context(), domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |
| 16 | + | require.NoError(t, err) | |
| 17 | + | defer terminal.Close() | |
| 18 | + | _, err = terminal.Write([]byte("hello")) | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | require.Equal(t, rune(' '), terminal.Snapshot().Cell(0, 0).Rune) | |
| 21 | + | require.NoError(t, terminal.Flush()) | |
| 22 | + | require.Equal(t, rune('h'), terminal.Snapshot().Cell(0, 0).Rune) | |
| 23 | + | old := terminal.Snapshot() | |
| 24 | + | geometry := domain.Geometry{Size: domain.Size{Cols: 20, Rows: 8}, PixelWidth: 200, PixelHeight: 160} | |
| 25 | + | require.NoError(t, terminal.Resize(geometry)) | |
| 26 | + | require.Equal(t, geometry, <-terminal.ResizeEvents()) | |
| 27 | + | require.NoError(t, terminal.Flush()) | |
| 28 | + | require.Equal(t, 20, terminal.Snapshot().Columns()) | |
| 29 | + | require.Equal(t, 10, old.Columns()) | |
| 30 | + | require.Error(t, terminal.Resize(domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}})) | |
| 31 | + | terminal.Close() | |
| 32 | + | _, err = terminal.Write([]byte("x")) | |
| 33 | + | require.ErrorIs(t, err, io.ErrClosedPipe) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestTerminalDefaultColorQueries(t *testing.T) { | |
| 37 | + | terminal, err := New(t.Context(), domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | defer terminal.Close() | |
| 40 | + | for _, tt := range []struct{ query, want string }{ | |
| 41 | + | {"\x1b]10;?\x1b\\", "\x1b]10;rgb:d8d8/d8d8/d8d8\x1b\\"}, | |
| 42 | + | {"\x1b]11;?\x1b\\", "\x1b]11;rgb:1010/1010/1010\x1b\\"}, | |
| 43 | + | } { | |
| 44 | + | _, err := terminal.Write([]byte(tt.query)) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | got := make([]byte, len(tt.want)) | |
| 47 | + | _, err = io.ReadFull(terminal.In(), got) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | require.Equal(t, tt.want, string(got)) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestTerminalInput(t *testing.T) { | |
| 54 | + | tests := []struct { | |
| 55 | + | name string | |
| 56 | + | event browser.Event | |
| 57 | + | want string | |
| 58 | + | }{ | |
| 59 | + | {"text", browser.Event{Kind: browser.EventText, Text: &browser.TextEvent{Text: "helloλ"}}, "helloλ"}, | |
| 60 | + | {"paste sanitizes framing", browser.Event{Kind: browser.EventPaste, Paste: &browser.PasteEvent{Text: "a\x1b[201~\x03\nb"}}, "\x1b[200~a[201~\nb\x1b[201~"}, | |
| 61 | + | {"palette", browser.Event{Kind: browser.EventKey, Key: &browser.KeyEvent{Key: " ", Modifiers: browser.Modifiers{Alt: true}}}, "\x1b "}, | |
| 62 | + | {"mouse", browser.Event{Kind: browser.EventPointer, Pointer: &browser.PointerEvent{Action: "down", Button: 0, Row: 1, Column: 2}}, "\x1b[<0;3;2M"}, | |
| 63 | + | } | |
| 64 | + | for _, tt := range tests { | |
| 65 | + | t.Run(tt.name, func(t *testing.T) { | |
| 66 | + | ctx, cancel := context.WithTimeout(t.Context(), time.Second) | |
| 67 | + | defer cancel() | |
| 68 | + | terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | defer terminal.Close() | |
| 71 | + | _, err = terminal.EnterRaw() | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | require.NoError(t, terminal.Handle(ctx, tt.event)) | |
| 74 | + | data := make([]byte, len(tt.want)) | |
| 75 | + | _, err = io.ReadFull(terminal.In(), data) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.Equal(t, tt.want, string(data)) | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestEncodeKey(t *testing.T) { | |
| 83 | + | tests := []struct { | |
| 84 | + | name, key string | |
| 85 | + | modifiers browser.Modifiers | |
| 86 | + | app bool | |
| 87 | + | want string | |
| 88 | + | }{ | |
| 89 | + | {"cursor", "ArrowUp", browser.Modifiers{}, false, "\x1b[A"}, | |
| 90 | + | {"application cursor", "ArrowUp", browser.Modifiers{}, true, "\x1bOA"}, | |
| 91 | + | {"modified cursor", "ArrowLeft", browser.Modifiers{Ctrl: true}, false, "\x1b[1;5D"}, | |
| 92 | + | {"interrupt", "c", browser.Modifiers{Ctrl: true}, false, "\x03"}, | |
| 93 | + | {"alt letter", "h", browser.Modifiers{Alt: true}, false, "\x1bh"}, | |
| 94 | + | {"shift tab", "Tab", browser.Modifiers{Shift: true}, false, "\x1b[Z"}, | |
| 95 | + | {"alt shift tab", "Tab", browser.Modifiers{Shift: true, Alt: true}, false, "\x1b\x1b[Z"}, | |
| 96 | + | {"delete", "Delete", browser.Modifiers{}, false, "\x1b[3~"}, | |
| 97 | + | {"function", "F12", browser.Modifiers{}, false, "\x1b[24~"}, | |
| 98 | + | {"meta reserved", "c", browser.Modifiers{Meta: true}, false, ""}, | |
| 99 | + | {"unknown", "Unidentified", browser.Modifiers{}, false, ""}, | |
| 100 | + | } | |
| 101 | + | for _, tt := range tests { | |
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |
| 103 | + | require.Equal(t, tt.want, encodeKey(browser.KeyEvent{Key: tt.key, Modifiers: tt.modifiers}, tt.app)) | |
| 104 | + | }) | |
| 105 | + | } | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "io" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev-vt/html/browser" | |||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestTerminalFlushResizeAndClose(t *testing.T) { | |||
| 15 | + | terminal, err := New(t.Context(), domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |||
| 16 | + | require.NoError(t, err) | |||
| 17 | + | defer terminal.Close() | |||
| 18 | + | _, err = terminal.Write([]byte("hello")) | |||
| 19 | + | require.NoError(t, err) | |||
| 20 | + | require.Equal(t, rune(' '), terminal.Snapshot().Cell(0, 0).Rune) | |||
| 21 | + | require.NoError(t, terminal.Flush()) | |||
| 22 | + | require.Equal(t, rune('h'), terminal.Snapshot().Cell(0, 0).Rune) | |||
| 23 | + | old := terminal.Snapshot() | |||
| 24 | + | geometry := domain.Geometry{Size: domain.Size{Cols: 20, Rows: 8}, PixelWidth: 200, PixelHeight: 160} | |||
| 25 | + | require.NoError(t, terminal.Resize(geometry)) | |||
| 26 | + | require.Equal(t, geometry, <-terminal.ResizeEvents()) | |||
| 27 | + | require.NoError(t, terminal.Flush()) | |||
| 28 | + | require.Equal(t, 20, terminal.Snapshot().Columns()) | |||
| 29 | + | require.Equal(t, 10, old.Columns()) | |||
| 30 | + | require.Error(t, terminal.Resize(domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}})) | |||
| 31 | + | terminal.Close() | |||
| 32 | + | _, err = terminal.Write([]byte("x")) | |||
| 33 | + | require.ErrorIs(t, err, io.ErrClosedPipe) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestTerminalDefaultColorQueries(t *testing.T) { | |||
| 37 | + | terminal, err := New(t.Context(), domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | defer terminal.Close() | |||
| 40 | + | for _, tt := range []struct{ query, want string }{ | |||
| 41 | + | {"\x1b]10;?\x1b\\", "\x1b]10;rgb:d8d8/d8d8/d8d8\x1b\\"}, | |||
| 42 | + | {"\x1b]11;?\x1b\\", "\x1b]11;rgb:1010/1010/1010\x1b\\"}, | |||
| 43 | + | } { | |||
| 44 | + | _, err := terminal.Write([]byte(tt.query)) | |||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | got := make([]byte, len(tt.want)) | |||
| 47 | + | _, err = io.ReadFull(terminal.In(), got) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | require.Equal(t, tt.want, string(got)) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestTerminalInput(t *testing.T) { | |||
| 54 | + | tests := []struct { | |||
| 55 | + | name string | |||
| 56 | + | event browser.Event | |||
| 57 | + | want string | |||
| 58 | + | }{ | |||
| 59 | + | {"text", browser.Event{Kind: browser.EventText, Text: &browser.TextEvent{Text: "helloλ"}}, "helloλ"}, | |||
| 60 | + | {"paste sanitizes framing", browser.Event{Kind: browser.EventPaste, Paste: &browser.PasteEvent{Text: "a\x1b[201~\x03\nb"}}, "\x1b[200~a[201~\nb\x1b[201~"}, | |||
| 61 | + | {"palette", browser.Event{Kind: browser.EventKey, Key: &browser.KeyEvent{Key: " ", Modifiers: browser.Modifiers{Alt: true}}}, "\x1b "}, | |||
| 62 | + | {"mouse", browser.Event{Kind: browser.EventPointer, Pointer: &browser.PointerEvent{Action: "down", Button: 0, Row: 1, Column: 2}}, "\x1b[<0;3;2M"}, | |||
| 63 | + | } | |||
| 64 | + | for _, tt := range tests { | |||
| 65 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 66 | + | ctx, cancel := context.WithTimeout(t.Context(), time.Second) | |||
| 67 | + | defer cancel() | |||
| 68 | + | terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | defer terminal.Close() | |||
| 71 | + | _, err = terminal.EnterRaw() | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | require.NoError(t, terminal.Handle(ctx, tt.event)) | |||
| 74 | + | data := make([]byte, len(tt.want)) | |||
| 75 | + | _, err = io.ReadFull(terminal.In(), data) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | require.Equal(t, tt.want, string(data)) | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestEncodeKey(t *testing.T) { | |||
| 83 | + | tests := []struct { | |||
| 84 | + | name, key string | |||
| 85 | + | modifiers browser.Modifiers | |||
| 86 | + | app bool | |||
| 87 | + | want string | |||
| 88 | + | }{ | |||
| 89 | + | {"cursor", "ArrowUp", browser.Modifiers{}, false, "\x1b[A"}, | |||
| 90 | + | {"application cursor", "ArrowUp", browser.Modifiers{}, true, "\x1bOA"}, | |||
| 91 | + | {"modified cursor", "ArrowLeft", browser.Modifiers{Ctrl: true}, false, "\x1b[1;5D"}, | |||
| 92 | + | {"interrupt", "c", browser.Modifiers{Ctrl: true}, false, "\x03"}, | |||
| 93 | + | {"alt letter", "h", browser.Modifiers{Alt: true}, false, "\x1bh"}, | |||
| 94 | + | {"shift tab", "Tab", browser.Modifiers{Shift: true}, false, "\x1b[Z"}, | |||
| 95 | + | {"alt shift tab", "Tab", browser.Modifiers{Shift: true, Alt: true}, false, "\x1b\x1b[Z"}, | |||
| 96 | + | {"delete", "Delete", browser.Modifiers{}, false, "\x1b[3~"}, | |||
| 97 | + | {"function", "F12", browser.Modifiers{}, false, "\x1b[24~"}, | |||
| 98 | + | {"meta reserved", "c", browser.Modifiers{Meta: true}, false, ""}, | |||
| 99 | + | {"unknown", "Unidentified", browser.Modifiers{}, false, ""}, | |||
| 100 | + | } | |||
| 101 | + | for _, tt := range tests { | |||
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 103 | + | require.Equal(t, tt.want, encodeKey(browser.KeyEvent{Key: tt.key, Modifiers: tt.modifiers}, tt.app)) | |||
| 104 | + | }) | |||
| 105 | + | } | |||
| 106 | + | } | |||
A internal/adapters/webterm/wheel.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | // Wheel quantization converts raw browser wheel deltas into discrete | |
| 4 | + | // terminal wheel reports. Browsers emit wheel events as a scroll distance, | |
| 5 | + | // not as physical notches: high-resolution wheels and trackpads produce a | |
| 6 | + | // burst of micro-events per notch, and forwarding one SGR report per event | |
| 7 | + | // scrolls dozens of lines per notch. The accumulator below merges fractions | |
| 8 | + | // until they form whole notches, so one notch yields one report, like an | |
| 9 | + | // ordinary terminal. The remainder is kept per terminal and bounded. | |
| 10 | + | const ( | |
| 11 | + | // wheelPixelsPerNotch matches the Chromium convention of 100 CSS pixels | |
| 12 | + | // per wheel notch in pixel (DOM_DELTA_PIXEL) mode. | |
| 13 | + | wheelPixelsPerNotch = 100 | |
| 14 | + | // wheelLinesPerNotch matches the browser convention of 3 lines per wheel | |
| 15 | + | // notch in line (DOM_DELTA_LINE) mode. | |
| 16 | + | wheelLinesPerNotch = 3 | |
| 17 | + | // wheelNotchesPerPage converts page (DOM_DELTA_PAGE) mode into notches. | |
| 18 | + | wheelNotchesPerPage = 10 | |
| 19 | + | // wheelMaxNotchesPerEvent caps the reports emitted for a single event. | |
| 20 | + | wheelMaxNotchesPerEvent = 10 | |
| 21 | + | // wheelMaxAccumulated caps the retained remainder in notch units, so a | |
| 22 | + | // violent momentum gesture cannot bank phantom scrolling. | |
| 23 | + | wheelMaxAccumulated = 10.0 | |
| 24 | + | // wheelShiftMultiplier is the fast-scroll factor while Shift is held. | |
| 25 | + | wheelShiftMultiplier = 10 | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // consumeWheel converts one wheel delta into whole-notch SGR reports. It | |
| 29 | + | // returns the report count and the SGR button (64 for up, 65 for down). | |
| 30 | + | // Sub-notch fractions accumulate on the terminal; a zero count means the | |
| 31 | + | // delta was banked for later. Shift multiplies the notch count for | |
| 32 | + | // predictable fast scrolling. | |
| 33 | + | func (t *Terminal) consumeWheel(deltaY float64, deltaMode int, shift bool) (int, int) { | |
| 34 | + | t.mu.Lock() | |
| 35 | + | defer t.mu.Unlock() | |
| 36 | + | t.wheelAcc += wheelNotches(deltaY, deltaMode) | |
| 37 | + | if t.wheelAcc > wheelMaxAccumulated { | |
| 38 | + | t.wheelAcc = wheelMaxAccumulated | |
| 39 | + | } else if t.wheelAcc < -wheelMaxAccumulated { | |
| 40 | + | t.wheelAcc = -wheelMaxAccumulated | |
| 41 | + | } | |
| 42 | + | notches := int(t.wheelAcc) | |
| 43 | + | t.wheelAcc -= float64(notches) | |
| 44 | + | if t.wheelAcc < 1e-9 && t.wheelAcc > -1e-9 { | |
| 45 | + | t.wheelAcc = 0 | |
| 46 | + | } | |
| 47 | + | if notches == 0 { | |
| 48 | + | return 0, 0 | |
| 49 | + | } | |
| 50 | + | magnitude := notches | |
| 51 | + | if magnitude < 0 { | |
| 52 | + | magnitude = -magnitude | |
| 53 | + | } | |
| 54 | + | if magnitude > wheelMaxNotchesPerEvent { | |
| 55 | + | magnitude = wheelMaxNotchesPerEvent | |
| 56 | + | if notches < 0 { | |
| 57 | + | notches = -magnitude | |
| 58 | + | } else { | |
| 59 | + | notches = magnitude | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | if shift { | |
| 63 | + | magnitude *= wheelShiftMultiplier | |
| 64 | + | if magnitude > wheelMaxNotchesPerEvent*wheelShiftMultiplier { | |
| 65 | + | magnitude = wheelMaxNotchesPerEvent * wheelShiftMultiplier | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | button := 65 | |
| 69 | + | if notches < 0 { | |
| 70 | + | button = 64 | |
| 71 | + | } | |
| 72 | + | return magnitude, button | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // resetWheel drops the banked wheel remainder. It runs when wheel input is | |
| 76 | + | // ignored (mouse tracking off, out-of-bounds cell) so a stale remainder | |
| 77 | + | // cannot leak into a later, unrelated scroll gesture. | |
| 78 | + | func (t *Terminal) resetWheel() { | |
| 79 | + | t.mu.Lock() | |
| 80 | + | defer t.mu.Unlock() | |
| 81 | + | t.wheelAcc = 0 | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // wheelNotches converts a raw wheel delta into notch units. Unknown delta | |
| 85 | + | // modes contribute nothing rather than guessing a scale. | |
| 86 | + | func wheelNotches(deltaY float64, deltaMode int) float64 { | |
| 87 | + | switch deltaMode { | |
| 88 | + | case 0: | |
| 89 | + | return deltaY / wheelPixelsPerNotch | |
| 90 | + | case 1: | |
| 91 | + | return deltaY / wheelLinesPerNotch | |
| 92 | + | case 2: | |
| 93 | + | return deltaY * wheelNotchesPerPage | |
| 94 | + | default: | |
| 95 | + | return 0 | |
| 96 | + | } | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | // Wheel quantization converts raw browser wheel deltas into discrete | |||
| 4 | + | // terminal wheel reports. Browsers emit wheel events as a scroll distance, | |||
| 5 | + | // not as physical notches: high-resolution wheels and trackpads produce a | |||
| 6 | + | // burst of micro-events per notch, and forwarding one SGR report per event | |||
| 7 | + | // scrolls dozens of lines per notch. The accumulator below merges fractions | |||
| 8 | + | // until they form whole notches, so one notch yields one report, like an | |||
| 9 | + | // ordinary terminal. The remainder is kept per terminal and bounded. | |||
| 10 | + | const ( | |||
| 11 | + | // wheelPixelsPerNotch matches the Chromium convention of 100 CSS pixels | |||
| 12 | + | // per wheel notch in pixel (DOM_DELTA_PIXEL) mode. | |||
| 13 | + | wheelPixelsPerNotch = 100 | |||
| 14 | + | // wheelLinesPerNotch matches the browser convention of 3 lines per wheel | |||
| 15 | + | // notch in line (DOM_DELTA_LINE) mode. | |||
| 16 | + | wheelLinesPerNotch = 3 | |||
| 17 | + | // wheelNotchesPerPage converts page (DOM_DELTA_PAGE) mode into notches. | |||
| 18 | + | wheelNotchesPerPage = 10 | |||
| 19 | + | // wheelMaxNotchesPerEvent caps the reports emitted for a single event. | |||
| 20 | + | wheelMaxNotchesPerEvent = 10 | |||
| 21 | + | // wheelMaxAccumulated caps the retained remainder in notch units, so a | |||
| 22 | + | // violent momentum gesture cannot bank phantom scrolling. | |||
| 23 | + | wheelMaxAccumulated = 10.0 | |||
| 24 | + | // wheelShiftMultiplier is the fast-scroll factor while Shift is held. | |||
| 25 | + | wheelShiftMultiplier = 10 | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // consumeWheel converts one wheel delta into whole-notch SGR reports. It | |||
| 29 | + | // returns the report count and the SGR button (64 for up, 65 for down). | |||
| 30 | + | // Sub-notch fractions accumulate on the terminal; a zero count means the | |||
| 31 | + | // delta was banked for later. Shift multiplies the notch count for | |||
| 32 | + | // predictable fast scrolling. | |||
| 33 | + | func (t *Terminal) consumeWheel(deltaY float64, deltaMode int, shift bool) (int, int) { | |||
| 34 | + | t.mu.Lock() | |||
| 35 | + | defer t.mu.Unlock() | |||
| 36 | + | t.wheelAcc += wheelNotches(deltaY, deltaMode) | |||
| 37 | + | if t.wheelAcc > wheelMaxAccumulated { | |||
| 38 | + | t.wheelAcc = wheelMaxAccumulated | |||
| 39 | + | } else if t.wheelAcc < -wheelMaxAccumulated { | |||
| 40 | + | t.wheelAcc = -wheelMaxAccumulated | |||
| 41 | + | } | |||
| 42 | + | notches := int(t.wheelAcc) | |||
| 43 | + | t.wheelAcc -= float64(notches) | |||
| 44 | + | if t.wheelAcc < 1e-9 && t.wheelAcc > -1e-9 { | |||
| 45 | + | t.wheelAcc = 0 | |||
| 46 | + | } | |||
| 47 | + | if notches == 0 { | |||
| 48 | + | return 0, 0 | |||
| 49 | + | } | |||
| 50 | + | magnitude := notches | |||
| 51 | + | if magnitude < 0 { | |||
| 52 | + | magnitude = -magnitude | |||
| 53 | + | } | |||
| 54 | + | if magnitude > wheelMaxNotchesPerEvent { | |||
| 55 | + | magnitude = wheelMaxNotchesPerEvent | |||
| 56 | + | if notches < 0 { | |||
| 57 | + | notches = -magnitude | |||
| 58 | + | } else { | |||
| 59 | + | notches = magnitude | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | if shift { | |||
| 63 | + | magnitude *= wheelShiftMultiplier | |||
| 64 | + | if magnitude > wheelMaxNotchesPerEvent*wheelShiftMultiplier { | |||
| 65 | + | magnitude = wheelMaxNotchesPerEvent * wheelShiftMultiplier | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | button := 65 | |||
| 69 | + | if notches < 0 { | |||
| 70 | + | button = 64 | |||
| 71 | + | } | |||
| 72 | + | return magnitude, button | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // resetWheel drops the banked wheel remainder. It runs when wheel input is | |||
| 76 | + | // ignored (mouse tracking off, out-of-bounds cell) so a stale remainder | |||
| 77 | + | // cannot leak into a later, unrelated scroll gesture. | |||
| 78 | + | func (t *Terminal) resetWheel() { | |||
| 79 | + | t.mu.Lock() | |||
| 80 | + | defer t.mu.Unlock() | |||
| 81 | + | t.wheelAcc = 0 | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // wheelNotches converts a raw wheel delta into notch units. Unknown delta | |||
| 85 | + | // modes contribute nothing rather than guessing a scale. | |||
| 86 | + | func wheelNotches(deltaY float64, deltaMode int) float64 { | |||
| 87 | + | switch deltaMode { | |||
| 88 | + | case 0: | |||
| 89 | + | return deltaY / wheelPixelsPerNotch | |||
| 90 | + | case 1: | |||
| 91 | + | return deltaY / wheelLinesPerNotch | |||
| 92 | + | case 2: | |||
| 93 | + | return deltaY * wheelNotchesPerPage | |||
| 94 | + | default: | |||
| 95 | + | return 0 | |||
| 96 | + | } | |||
| 97 | + | } | |||
A internal/adapters/webterm/wheel_test.go +245 -0
| @@ -0,0 +1,245 @@ | |||
| 1 | + | package webterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev-vt/html/browser" | |
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestWheelNotchesConvertModes(t *testing.T) { | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | deltaY float64 | |
| 19 | + | deltaMode int | |
| 20 | + | want float64 | |
| 21 | + | }{ | |
| 22 | + | {"pixel notch", 100, 0, 1}, | |
| 23 | + | {"pixel fraction", 25, 0, 0.25}, | |
| 24 | + | {"pixel negative", -100, 0, -1}, | |
| 25 | + | {"line notch", 3, 1, 1}, | |
| 26 | + | {"line fraction", 1, 1, 1.0 / 3.0}, | |
| 27 | + | {"page notch", 1, 2, 10}, | |
| 28 | + | {"page negative", -1, 2, -10}, | |
| 29 | + | {"unknown mode", 100, 7, 0}, | |
| 30 | + | } | |
| 31 | + | for _, test := range tests { | |
| 32 | + | t.Run(test.name, func(t *testing.T) { | |
| 33 | + | if got := wheelNotches(test.deltaY, test.deltaMode); got != test.want { | |
| 34 | + | t.Fatalf("wheelNotches(%v, %d) = %v, want %v", test.deltaY, test.deltaMode, got, test.want) | |
| 35 | + | } | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestConsumeWheelQuantizesNotches(t *testing.T) { | |
| 41 | + | tests := []struct { | |
| 42 | + | name string | |
| 43 | + | deltas []float64 | |
| 44 | + | mode int | |
| 45 | + | shift bool | |
| 46 | + | wantReports []int | |
| 47 | + | wantUp []bool | |
| 48 | + | wantRemainder float64 | |
| 49 | + | }{ | |
| 50 | + | { | |
| 51 | + | name: "one pixel notch down", | |
| 52 | + | deltas: []float64{100}, | |
| 53 | + | wantReports: []int{1}, | |
| 54 | + | wantUp: []bool{false}, | |
| 55 | + | wantRemainder: 0, | |
| 56 | + | }, | |
| 57 | + | { | |
| 58 | + | name: "one pixel notch up", | |
| 59 | + | deltas: []float64{-100}, | |
| 60 | + | wantReports: []int{1}, | |
| 61 | + | wantUp: []bool{true}, | |
| 62 | + | wantRemainder: 0, | |
| 63 | + | }, | |
| 64 | + | { | |
| 65 | + | name: "micro burst forms one notch", | |
| 66 | + | deltas: []float64{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}, | |
| 67 | + | wantReports: []int{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, | |
| 68 | + | wantUp: make([]bool, 20), | |
| 69 | + | wantRemainder: 0, | |
| 70 | + | }, | |
| 71 | + | { | |
| 72 | + | name: "same distance at once or fragmented", | |
| 73 | + | deltas: []float64{200}, | |
| 74 | + | wantReports: []int{2}, | |
| 75 | + | wantUp: []bool{false}, | |
| 76 | + | wantRemainder: 0, | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | name: "line mode notch", | |
| 80 | + | deltas: []float64{3}, | |
| 81 | + | mode: 1, | |
| 82 | + | wantReports: []int{1}, | |
| 83 | + | wantUp: []bool{false}, | |
| 84 | + | wantRemainder: 0, | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "remainder carries over", | |
| 88 | + | deltas: []float64{150, 150}, | |
| 89 | + | wantReports: []int{1, 2}, | |
| 90 | + | wantUp: []bool{false, false}, | |
| 91 | + | wantRemainder: 0, | |
| 92 | + | }, | |
| 93 | + | { | |
| 94 | + | name: "shift multiplies notches", | |
| 95 | + | deltas: []float64{100}, | |
| 96 | + | shift: true, | |
| 97 | + | wantReports: []int{10}, | |
| 98 | + | wantUp: []bool{false}, | |
| 99 | + | wantRemainder: 0, | |
| 100 | + | }, | |
| 101 | + | { | |
| 102 | + | name: "emission is capped", | |
| 103 | + | deltas: []float64{5000}, | |
| 104 | + | wantReports: []int{10}, | |
| 105 | + | wantUp: []bool{false}, | |
| 106 | + | wantRemainder: 0, | |
| 107 | + | }, | |
| 108 | + | { | |
| 109 | + | name: "accumulator is capped", | |
| 110 | + | deltas: []float64{50000, 0}, | |
| 111 | + | wantReports: []int{10, 0}, | |
| 112 | + | wantUp: []bool{false, false}, | |
| 113 | + | wantRemainder: 0, | |
| 114 | + | }, | |
| 115 | + | } | |
| 116 | + | for _, test := range tests { | |
| 117 | + | t.Run(test.name, func(t *testing.T) { | |
| 118 | + | terminal := &Terminal{} | |
| 119 | + | if len(test.wantReports) != len(test.deltas) { | |
| 120 | + | t.Fatalf("test declares %d deltas but %d expectations", len(test.deltas), len(test.wantReports)) | |
| 121 | + | } | |
| 122 | + | for i, delta := range test.deltas { | |
| 123 | + | reports, button := terminal.consumeWheel(delta, test.mode, test.shift) | |
| 124 | + | if reports != test.wantReports[i] { | |
| 125 | + | t.Fatalf("delta %d: reports = %d, want %d", i, reports, test.wantReports[i]) | |
| 126 | + | } | |
| 127 | + | if reports == 0 { | |
| 128 | + | continue | |
| 129 | + | } | |
| 130 | + | wantButton := 65 | |
| 131 | + | if test.wantUp[i] { | |
| 132 | + | wantButton = 64 | |
| 133 | + | } | |
| 134 | + | if button != wantButton { | |
| 135 | + | t.Fatalf("delta %d: button = %d, want %d", i, button, wantButton) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | if terminal.wheelAcc != test.wantRemainder { | |
| 139 | + | t.Fatalf("remainder = %v, want %v", terminal.wheelAcc, test.wantRemainder) | |
| 140 | + | } | |
| 141 | + | }) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestConsumeWheelKeepsDirectionAcrossFractions(t *testing.T) { | |
| 146 | + | terminal := &Terminal{} | |
| 147 | + | if reports, _ := terminal.consumeWheel(-90, 0, false); reports != 0 { | |
| 148 | + | t.Fatalf("reports = %d, want 0 (banked)", reports) | |
| 149 | + | } | |
| 150 | + | reports, button := terminal.consumeWheel(-30, 0, false) | |
| 151 | + | if reports != 1 || button != 64 { | |
| 152 | + | t.Fatalf("reports = %d button = %d, want 1/64", reports, button) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestWheelHorizontalOnlyPreservesRemainder(t *testing.T) { | |
| 157 | + | ctx, cancel := context.WithTimeout(t.Context(), time.Second) | |
| 158 | + | defer cancel() | |
| 159 | + | terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | defer terminal.Close() | |
| 162 | + | _, err = terminal.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 163 | + | require.NoError(t, err) | |
| 164 | + | require.NoError(t, terminal.Flush()) | |
| 165 | + | up := browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{DeltaY: 90, DeltaMode: 0, Row: 0, Column: 0}} | |
| 166 | + | require.NoError(t, terminal.Handle(ctx, up)) | |
| 167 | + | if terminal.wheelAcc != 0.9 { | |
| 168 | + | t.Fatalf("remainder = %v, want 0.9", terminal.wheelAcc) | |
| 169 | + | } | |
| 170 | + | // A pure horizontal swipe is ignored without wiping banked vertical. | |
| 171 | + | horizontal := browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{DeltaX: 50, DeltaY: 0, DeltaMode: 0, Row: 0, Column: 0}} | |
| 172 | + | require.NoError(t, terminal.Handle(ctx, horizontal)) | |
| 173 | + | if terminal.wheelAcc != 0.9 { | |
| 174 | + | t.Fatalf("remainder = %v, want 0.9 (horizontal must not reset)", terminal.wheelAcc) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestWheelIgnoredInputResetsRemainder(t *testing.T) { | |
| 179 | + | terminal := &Terminal{} | |
| 180 | + | ctx := context.Background() | |
| 181 | + | down := browser.Event{ | |
| 182 | + | Kind: browser.EventWheel, | |
| 183 | + | Wheel: &browser.WheelEvent{ | |
| 184 | + | DeltaY: 90, DeltaMode: 0, Row: 9999, Column: 9999, | |
| 185 | + | }, | |
| 186 | + | } | |
| 187 | + | // Out-of-bounds cells are ignored and must drop the banked fraction. | |
| 188 | + | if err := terminal.Handle(ctx, down); err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | if terminal.wheelAcc != 0 { | |
| 192 | + | t.Fatalf("remainder = %v, want 0", terminal.wheelAcc) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestHandleWheelShiftOmitsShiftModifier(t *testing.T) { | |
| 197 | + | ctx, cancel := context.WithTimeout(t.Context(), time.Second) | |
| 198 | + | defer cancel() | |
| 199 | + | terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | defer terminal.Close() | |
| 202 | + | _, err = terminal.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 203 | + | require.NoError(t, err) | |
| 204 | + | require.NoError(t, terminal.Flush()) | |
| 205 | + | ||
| 206 | + | // Shift is consumed as the x10 multiplier: reports stay plain wheel | |
| 207 | + | // buttons (65) instead of Shift-flagged buttons (69). | |
| 208 | + | event := browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{ | |
| 209 | + | DeltaY: 100, DeltaMode: 0, Row: 0, Column: 0, Modifiers: browser.Modifiers{Shift: true}, | |
| 210 | + | }} | |
| 211 | + | require.NoError(t, terminal.Handle(ctx, event)) | |
| 212 | + | want := strings.Repeat("\x1b[<65;1;1M", 10) | |
| 213 | + | data := make([]byte, len(want)) | |
| 214 | + | _, err = io.ReadFull(terminal.In(), data) | |
| 215 | + | require.NoError(t, err) | |
| 216 | + | require.Equal(t, want, string(data)) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestHandleWheelEmitsOneReportPerNotch(t *testing.T) { | |
| 220 | + | ctx, cancel := context.WithTimeout(t.Context(), time.Second) | |
| 221 | + | defer cancel() | |
| 222 | + | terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | defer terminal.Close() | |
| 225 | + | _, err = terminal.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | require.NoError(t, terminal.Flush()) | |
| 228 | + | ||
| 229 | + | wheel := func(deltaY float64) browser.Event { | |
| 230 | + | return browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{ | |
| 231 | + | DeltaY: deltaY, DeltaMode: 0, Row: 0, Column: 0, | |
| 232 | + | }} | |
| 233 | + | } | |
| 234 | + | // A 5px micro-event is banked: nothing is sent yet. | |
| 235 | + | require.NoError(t, terminal.Handle(ctx, wheel(5))) | |
| 236 | + | // Nineteen more micro-events complete the notch: exactly one report. | |
| 237 | + | for i := 0; i < 19; i++ { | |
| 238 | + | require.NoError(t, terminal.Handle(ctx, wheel(5))) | |
| 239 | + | } | |
| 240 | + | want := "\x1b[<65;1;1M" | |
| 241 | + | data := make([]byte, len(want)) | |
| 242 | + | _, err = io.ReadFull(terminal.In(), data) | |
| 243 | + | require.NoError(t, err) | |
| 244 | + | require.Equal(t, want, string(data)) | |
| 245 | + | } | |
| @@ -0,0 +1,245 @@ | |||||
| 1 | + | package webterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "io" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev-vt/html/browser" | |||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestWheelNotchesConvertModes(t *testing.T) { | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | deltaY float64 | |||
| 19 | + | deltaMode int | |||
| 20 | + | want float64 | |||
| 21 | + | }{ | |||
| 22 | + | {"pixel notch", 100, 0, 1}, | |||
| 23 | + | {"pixel fraction", 25, 0, 0.25}, | |||
| 24 | + | {"pixel negative", -100, 0, -1}, | |||
| 25 | + | {"line notch", 3, 1, 1}, | |||
| 26 | + | {"line fraction", 1, 1, 1.0 / 3.0}, | |||
| 27 | + | {"page notch", 1, 2, 10}, | |||
| 28 | + | {"page negative", -1, 2, -10}, | |||
| 29 | + | {"unknown mode", 100, 7, 0}, | |||
| 30 | + | } | |||
| 31 | + | for _, test := range tests { | |||
| 32 | + | t.Run(test.name, func(t *testing.T) { | |||
| 33 | + | if got := wheelNotches(test.deltaY, test.deltaMode); got != test.want { | |||
| 34 | + | t.Fatalf("wheelNotches(%v, %d) = %v, want %v", test.deltaY, test.deltaMode, got, test.want) | |||
| 35 | + | } | |||
| 36 | + | }) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestConsumeWheelQuantizesNotches(t *testing.T) { | |||
| 41 | + | tests := []struct { | |||
| 42 | + | name string | |||
| 43 | + | deltas []float64 | |||
| 44 | + | mode int | |||
| 45 | + | shift bool | |||
| 46 | + | wantReports []int | |||
| 47 | + | wantUp []bool | |||
| 48 | + | wantRemainder float64 | |||
| 49 | + | }{ | |||
| 50 | + | { | |||
| 51 | + | name: "one pixel notch down", | |||
| 52 | + | deltas: []float64{100}, | |||
| 53 | + | wantReports: []int{1}, | |||
| 54 | + | wantUp: []bool{false}, | |||
| 55 | + | wantRemainder: 0, | |||
| 56 | + | }, | |||
| 57 | + | { | |||
| 58 | + | name: "one pixel notch up", | |||
| 59 | + | deltas: []float64{-100}, | |||
| 60 | + | wantReports: []int{1}, | |||
| 61 | + | wantUp: []bool{true}, | |||
| 62 | + | wantRemainder: 0, | |||
| 63 | + | }, | |||
| 64 | + | { | |||
| 65 | + | name: "micro burst forms one notch", | |||
| 66 | + | deltas: []float64{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}, | |||
| 67 | + | wantReports: []int{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, | |||
| 68 | + | wantUp: make([]bool, 20), | |||
| 69 | + | wantRemainder: 0, | |||
| 70 | + | }, | |||
| 71 | + | { | |||
| 72 | + | name: "same distance at once or fragmented", | |||
| 73 | + | deltas: []float64{200}, | |||
| 74 | + | wantReports: []int{2}, | |||
| 75 | + | wantUp: []bool{false}, | |||
| 76 | + | wantRemainder: 0, | |||
| 77 | + | }, | |||
| 78 | + | { | |||
| 79 | + | name: "line mode notch", | |||
| 80 | + | deltas: []float64{3}, | |||
| 81 | + | mode: 1, | |||
| 82 | + | wantReports: []int{1}, | |||
| 83 | + | wantUp: []bool{false}, | |||
| 84 | + | wantRemainder: 0, | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "remainder carries over", | |||
| 88 | + | deltas: []float64{150, 150}, | |||
| 89 | + | wantReports: []int{1, 2}, | |||
| 90 | + | wantUp: []bool{false, false}, | |||
| 91 | + | wantRemainder: 0, | |||
| 92 | + | }, | |||
| 93 | + | { | |||
| 94 | + | name: "shift multiplies notches", | |||
| 95 | + | deltas: []float64{100}, | |||
| 96 | + | shift: true, | |||
| 97 | + | wantReports: []int{10}, | |||
| 98 | + | wantUp: []bool{false}, | |||
| 99 | + | wantRemainder: 0, | |||
| 100 | + | }, | |||
| 101 | + | { | |||
| 102 | + | name: "emission is capped", | |||
| 103 | + | deltas: []float64{5000}, | |||
| 104 | + | wantReports: []int{10}, | |||
| 105 | + | wantUp: []bool{false}, | |||
| 106 | + | wantRemainder: 0, | |||
| 107 | + | }, | |||
| 108 | + | { | |||
| 109 | + | name: "accumulator is capped", | |||
| 110 | + | deltas: []float64{50000, 0}, | |||
| 111 | + | wantReports: []int{10, 0}, | |||
| 112 | + | wantUp: []bool{false, false}, | |||
| 113 | + | wantRemainder: 0, | |||
| 114 | + | }, | |||
| 115 | + | } | |||
| 116 | + | for _, test := range tests { | |||
| 117 | + | t.Run(test.name, func(t *testing.T) { | |||
| 118 | + | terminal := &Terminal{} | |||
| 119 | + | if len(test.wantReports) != len(test.deltas) { | |||
| 120 | + | t.Fatalf("test declares %d deltas but %d expectations", len(test.deltas), len(test.wantReports)) | |||
| 121 | + | } | |||
| 122 | + | for i, delta := range test.deltas { | |||
| 123 | + | reports, button := terminal.consumeWheel(delta, test.mode, test.shift) | |||
| 124 | + | if reports != test.wantReports[i] { | |||
| 125 | + | t.Fatalf("delta %d: reports = %d, want %d", i, reports, test.wantReports[i]) | |||
| 126 | + | } | |||
| 127 | + | if reports == 0 { | |||
| 128 | + | continue | |||
| 129 | + | } | |||
| 130 | + | wantButton := 65 | |||
| 131 | + | if test.wantUp[i] { | |||
| 132 | + | wantButton = 64 | |||
| 133 | + | } | |||
| 134 | + | if button != wantButton { | |||
| 135 | + | t.Fatalf("delta %d: button = %d, want %d", i, button, wantButton) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | if terminal.wheelAcc != test.wantRemainder { | |||
| 139 | + | t.Fatalf("remainder = %v, want %v", terminal.wheelAcc, test.wantRemainder) | |||
| 140 | + | } | |||
| 141 | + | }) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestConsumeWheelKeepsDirectionAcrossFractions(t *testing.T) { | |||
| 146 | + | terminal := &Terminal{} | |||
| 147 | + | if reports, _ := terminal.consumeWheel(-90, 0, false); reports != 0 { | |||
| 148 | + | t.Fatalf("reports = %d, want 0 (banked)", reports) | |||
| 149 | + | } | |||
| 150 | + | reports, button := terminal.consumeWheel(-30, 0, false) | |||
| 151 | + | if reports != 1 || button != 64 { | |||
| 152 | + | t.Fatalf("reports = %d button = %d, want 1/64", reports, button) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestWheelHorizontalOnlyPreservesRemainder(t *testing.T) { | |||
| 157 | + | ctx, cancel := context.WithTimeout(t.Context(), time.Second) | |||
| 158 | + | defer cancel() | |||
| 159 | + | terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | defer terminal.Close() | |||
| 162 | + | _, err = terminal.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 163 | + | require.NoError(t, err) | |||
| 164 | + | require.NoError(t, terminal.Flush()) | |||
| 165 | + | up := browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{DeltaY: 90, DeltaMode: 0, Row: 0, Column: 0}} | |||
| 166 | + | require.NoError(t, terminal.Handle(ctx, up)) | |||
| 167 | + | if terminal.wheelAcc != 0.9 { | |||
| 168 | + | t.Fatalf("remainder = %v, want 0.9", terminal.wheelAcc) | |||
| 169 | + | } | |||
| 170 | + | // A pure horizontal swipe is ignored without wiping banked vertical. | |||
| 171 | + | horizontal := browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{DeltaX: 50, DeltaY: 0, DeltaMode: 0, Row: 0, Column: 0}} | |||
| 172 | + | require.NoError(t, terminal.Handle(ctx, horizontal)) | |||
| 173 | + | if terminal.wheelAcc != 0.9 { | |||
| 174 | + | t.Fatalf("remainder = %v, want 0.9 (horizontal must not reset)", terminal.wheelAcc) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestWheelIgnoredInputResetsRemainder(t *testing.T) { | |||
| 179 | + | terminal := &Terminal{} | |||
| 180 | + | ctx := context.Background() | |||
| 181 | + | down := browser.Event{ | |||
| 182 | + | Kind: browser.EventWheel, | |||
| 183 | + | Wheel: &browser.WheelEvent{ | |||
| 184 | + | DeltaY: 90, DeltaMode: 0, Row: 9999, Column: 9999, | |||
| 185 | + | }, | |||
| 186 | + | } | |||
| 187 | + | // Out-of-bounds cells are ignored and must drop the banked fraction. | |||
| 188 | + | if err := terminal.Handle(ctx, down); err != nil { | |||
| 189 | + | t.Fatal(err) | |||
| 190 | + | } | |||
| 191 | + | if terminal.wheelAcc != 0 { | |||
| 192 | + | t.Fatalf("remainder = %v, want 0", terminal.wheelAcc) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestHandleWheelShiftOmitsShiftModifier(t *testing.T) { | |||
| 197 | + | ctx, cancel := context.WithTimeout(t.Context(), time.Second) | |||
| 198 | + | defer cancel() | |||
| 199 | + | terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |||
| 200 | + | require.NoError(t, err) | |||
| 201 | + | defer terminal.Close() | |||
| 202 | + | _, err = terminal.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 203 | + | require.NoError(t, err) | |||
| 204 | + | require.NoError(t, terminal.Flush()) | |||
| 205 | + | ||||
| 206 | + | // Shift is consumed as the x10 multiplier: reports stay plain wheel | |||
| 207 | + | // buttons (65) instead of Shift-flagged buttons (69). | |||
| 208 | + | event := browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{ | |||
| 209 | + | DeltaY: 100, DeltaMode: 0, Row: 0, Column: 0, Modifiers: browser.Modifiers{Shift: true}, | |||
| 210 | + | }} | |||
| 211 | + | require.NoError(t, terminal.Handle(ctx, event)) | |||
| 212 | + | want := strings.Repeat("\x1b[<65;1;1M", 10) | |||
| 213 | + | data := make([]byte, len(want)) | |||
| 214 | + | _, err = io.ReadFull(terminal.In(), data) | |||
| 215 | + | require.NoError(t, err) | |||
| 216 | + | require.Equal(t, want, string(data)) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func TestHandleWheelEmitsOneReportPerNotch(t *testing.T) { | |||
| 220 | + | ctx, cancel := context.WithTimeout(t.Context(), time.Second) | |||
| 221 | + | defer cancel() | |||
| 222 | + | terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}}) | |||
| 223 | + | require.NoError(t, err) | |||
| 224 | + | defer terminal.Close() | |||
| 225 | + | _, err = terminal.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 226 | + | require.NoError(t, err) | |||
| 227 | + | require.NoError(t, terminal.Flush()) | |||
| 228 | + | ||||
| 229 | + | wheel := func(deltaY float64) browser.Event { | |||
| 230 | + | return browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{ | |||
| 231 | + | DeltaY: deltaY, DeltaMode: 0, Row: 0, Column: 0, | |||
| 232 | + | }} | |||
| 233 | + | } | |||
| 234 | + | // A 5px micro-event is banked: nothing is sent yet. | |||
| 235 | + | require.NoError(t, terminal.Handle(ctx, wheel(5))) | |||
| 236 | + | // Nineteen more micro-events complete the notch: exactly one report. | |||
| 237 | + | for i := 0; i < 19; i++ { | |||
| 238 | + | require.NoError(t, terminal.Handle(ctx, wheel(5))) | |||
| 239 | + | } | |||
| 240 | + | want := "\x1b[<65;1;1M" | |||
| 241 | + | data := make([]byte, len(want)) | |||
| 242 | + | _, err = io.ReadFull(terminal.In(), data) | |||
| 243 | + | require.NoError(t, err) | |||
| 244 | + | require.Equal(t, want, string(data)) | |||
| 245 | + | } | |||
M internal/app/run.go +20 -0
| @@ -73,6 +73,9 @@ const ( | |||
| 73 | 73 | kindRemotePreview | |
| 74 | 74 | kindUIDriver | |
| 75 | 75 | kindUIRemoteCleanup | |
| 76 | + | kindWebDaemon | |
| 77 | + | kindWebRenew | |
| 78 | + | kindWebServe | |
| 76 | 79 | kindHelp | |
| 77 | 80 | kindVersion | |
| 78 | 81 | ) | |
| @@ -96,6 +99,7 @@ type command struct { | |||
| 96 | 99 | uiObserve bool | |
| 97 | 100 | uiControl bool | |
| 98 | 101 | uiSocket string | |
| 102 | + | web webOptions | |
| 99 | 103 | } | |
| 100 | 104 | ||
| 101 | 105 | // usageError is a user-facing argument error; the app prints it (with usage) | |
| @@ -130,6 +134,9 @@ usage: | |||
| 130 | 134 | (optional: --ui-socket PATH) | |
| 131 | 135 | vev --ui-control expose observation and input control for this client | |
| 132 | 136 | (optional: --ui-socket PATH) | |
| 137 | + | vev --web-daemon start the private web terminal or print its current link | |
| 138 | + | vev --web-renew-token revoke web access and print a fresh link | |
| 139 | + | Web options: --web-listen IP:PORT --web-origin http(s)://HOST[:PORT] | |
| 133 | 140 | vev --help show this help | |
| 134 | 141 | vev --version show version` | |
| 135 | 142 | ||
| @@ -214,6 +221,13 @@ parsedUIFlags: | |||
| 214 | 221 | } | |
| 215 | 222 | ||
| 216 | 223 | switch args[0] { | |
| 224 | + | case "--web-daemon", "--web-serve": | |
| 225 | + | return parseWebArgs(args) | |
| 226 | + | case "--web-renew-token": | |
| 227 | + | if len(args) != 1 { | |
| 228 | + | return command{}, usagef("`%s` does not accept arguments", args[0]) | |
| 229 | + | } | |
| 230 | + | return command{kind: kindWebRenew}, nil | |
| 217 | 231 | case "--daemon": | |
| 218 | 232 | return command{kind: kindDaemon}, nil | |
| 219 | 233 | case "--daemon-launcher": | |
| @@ -332,6 +346,12 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 332 | 346 | case kindVersion: | |
| 333 | 347 | fmt.Println(versionLine()) | |
| 334 | 348 | return nil | |
| 349 | + | case kindWebRenew: | |
| 350 | + | return renewWebToken(ctx) | |
| 351 | + | case kindWebDaemon: | |
| 352 | + | return launchWebDaemon(ctx, cmd.web) | |
| 353 | + | case kindWebServe: | |
| 354 | + | return runWebDaemon(ctx, cmd.web) | |
| 335 | 355 | case kindDaemon: | |
| 336 | 356 | return runDaemon() | |
| 337 | 357 | case kindDaemonLauncher: | |
| @@ -73,6 +73,9 @@ const ( | |||||
| 73 | kindRemotePreview | 73 | kindRemotePreview | ||
| 74 | kindUIDriver | 74 | kindUIDriver | ||
| 75 | kindUIRemoteCleanup | 75 | kindUIRemoteCleanup | ||
| 76 | + | kindWebDaemon | |||
| 77 | + | kindWebRenew | |||
| 78 | + | kindWebServe | |||
| 76 | kindHelp | 79 | kindHelp | ||
| 77 | kindVersion | 80 | kindVersion | ||
| 78 | ) | 81 | ) | ||
| @@ -96,6 +99,7 @@ type command struct { | |||||
| 96 | uiObserve bool | 99 | uiObserve bool | ||
| 97 | uiControl bool | 100 | uiControl bool | ||
| 98 | uiSocket string | 101 | uiSocket string | ||
| 102 | + | web webOptions | |||
| 99 | } | 103 | } | ||
| 100 | 104 | ||||
| 101 | // usageError is a user-facing argument error; the app prints it (with usage) | 105 | // usageError is a user-facing argument error; the app prints it (with usage) | ||
| @@ -130,6 +134,9 @@ usage: | |||||
| 130 | (optional: --ui-socket PATH) | 134 | (optional: --ui-socket PATH) | ||
| 131 | vev --ui-control expose observation and input control for this client | 135 | vev --ui-control expose observation and input control for this client | ||
| 132 | (optional: --ui-socket PATH) | 136 | (optional: --ui-socket PATH) | ||
| 137 | + | vev --web-daemon start the private web terminal or print its current link | |||
| 138 | + | vev --web-renew-token revoke web access and print a fresh link | |||
| 139 | + | Web options: --web-listen IP:PORT --web-origin http(s)://HOST[:PORT] | |||
| 133 | vev --help show this help | 140 | vev --help show this help | ||
| 134 | vev --version show version` | 141 | vev --version show version` | ||
| 135 | 142 | ||||
| @@ -214,6 +221,13 @@ parsedUIFlags: | |||||
| 214 | } | 221 | } | ||
| 215 | 222 | ||||
| 216 | switch args[0] { | 223 | switch args[0] { | ||
| 224 | + | case "--web-daemon", "--web-serve": | |||
| 225 | + | return parseWebArgs(args) | |||
| 226 | + | case "--web-renew-token": | |||
| 227 | + | if len(args) != 1 { | |||
| 228 | + | return command{}, usagef("`%s` does not accept arguments", args[0]) | |||
| 229 | + | } | |||
| 230 | + | return command{kind: kindWebRenew}, nil | |||
| 217 | case "--daemon": | 231 | case "--daemon": | ||
| 218 | return command{kind: kindDaemon}, nil | 232 | return command{kind: kindDaemon}, nil | ||
| 219 | case "--daemon-launcher": | 233 | case "--daemon-launcher": | ||
| @@ -332,6 +346,12 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 332 | case kindVersion: | 346 | case kindVersion: | ||
| 333 | fmt.Println(versionLine()) | 347 | fmt.Println(versionLine()) | ||
| 334 | return nil | 348 | return nil | ||
| 349 | + | case kindWebRenew: | |||
| 350 | + | return renewWebToken(ctx) | |||
| 351 | + | case kindWebDaemon: | |||
| 352 | + | return launchWebDaemon(ctx, cmd.web) | |||
| 353 | + | case kindWebServe: | |||
| 354 | + | return runWebDaemon(ctx, cmd.web) | |||
| 335 | case kindDaemon: | 355 | case kindDaemon: | ||
| 336 | return runDaemon() | 356 | return runDaemon() | ||
| 337 | case kindDaemonLauncher: | 357 | case kindDaemonLauncher: | ||
A internal/app/web_control.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "net" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/adapters/webterm" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type webAccess struct { | |
| 16 | + | Token string `json:"token"` | |
| 17 | + | Settings webterm.Settings `json:"settings"` | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func startWebControl(ctx context.Context, server *webterm.Server, settings webterm.Settings) (*net.UnixListener, error) { | |
| 21 | + | listener, err := bindWebControl() | |
| 22 | + | if err != nil { | |
| 23 | + | return nil, err | |
| 24 | + | } | |
| 25 | + | go func() { | |
| 26 | + | stop := context.AfterFunc(ctx, func() { _ = listener.Close() }) | |
| 27 | + | defer stop() | |
| 28 | + | for { | |
| 29 | + | conn, err := listener.AcceptUnix() | |
| 30 | + | if err != nil { | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | // Serialize tiny bounded control requests. No unbounded per-peer workers. | |
| 34 | + | func() { | |
| 35 | + | defer conn.Close() | |
| 36 | + | if !sameWebUID(conn) { | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | _ = conn.SetDeadline(time.Now().Add(time.Second)) | |
| 40 | + | var command [1]byte | |
| 41 | + | if _, err := io.ReadFull(conn, command[:]); err != nil { | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | var token string | |
| 45 | + | switch command[0] { | |
| 46 | + | case 'G': | |
| 47 | + | token = server.Token() | |
| 48 | + | case 'R': | |
| 49 | + | token, err = server.RenewToken() | |
| 50 | + | if err != nil { | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | default: | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | _ = json.NewEncoder(conn).Encode(webAccess{Token: token, Settings: settings}) | |
| 57 | + | }() | |
| 58 | + | } | |
| 59 | + | }() | |
| 60 | + | return listener, nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func webControlRequest(ctx context.Context, renew bool) (webAccess, error) { | |
| 64 | + | ctx, cancel := context.WithTimeout(ctx, time.Second) | |
| 65 | + | defer cancel() | |
| 66 | + | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", webControlAddress()) | |
| 67 | + | if err != nil { | |
| 68 | + | return webAccess{}, err | |
| 69 | + | } | |
| 70 | + | defer conn.Close() | |
| 71 | + | unixConn, ok := conn.(*net.UnixConn) | |
| 72 | + | if !ok { | |
| 73 | + | return webAccess{}, errors.New("vev: unsafe web control peer") | |
| 74 | + | } | |
| 75 | + | if !sameWebUID(unixConn) { | |
| 76 | + | return webAccess{}, errors.New("vev: unsafe web control peer") | |
| 77 | + | } | |
| 78 | + | deadline, _ := ctx.Deadline() | |
| 79 | + | _ = conn.SetDeadline(deadline) | |
| 80 | + | command := byte('G') | |
| 81 | + | if renew { | |
| 82 | + | command = 'R' | |
| 83 | + | } | |
| 84 | + | if _, err := conn.Write([]byte{command}); err != nil { | |
| 85 | + | return webAccess{}, err | |
| 86 | + | } | |
| 87 | + | data, err := io.ReadAll(io.LimitReader(conn, 4097)) | |
| 88 | + | if err != nil { | |
| 89 | + | return webAccess{}, err | |
| 90 | + | } | |
| 91 | + | var access webAccess | |
| 92 | + | if len(data) > 4096 || json.Unmarshal(data, &access) != nil { | |
| 93 | + | return webAccess{}, errors.New("vev: invalid web control response") | |
| 94 | + | } | |
| 95 | + | decoded, err := base64.RawURLEncoding.DecodeString(access.Token) | |
| 96 | + | settings, settingsErr := webterm.ParseSettings(access.Settings.Listen, access.Settings.Origin) | |
| 97 | + | if err != nil || len(decoded) != 32 || settingsErr != nil || settings != access.Settings { | |
| 98 | + | return webAccess{}, errors.New("vev: invalid web control response") | |
| 99 | + | } | |
| 100 | + | return access, nil | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/base64" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | "net" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/adapters/webterm" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type webAccess struct { | |||
| 16 | + | Token string `json:"token"` | |||
| 17 | + | Settings webterm.Settings `json:"settings"` | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func startWebControl(ctx context.Context, server *webterm.Server, settings webterm.Settings) (*net.UnixListener, error) { | |||
| 21 | + | listener, err := bindWebControl() | |||
| 22 | + | if err != nil { | |||
| 23 | + | return nil, err | |||
| 24 | + | } | |||
| 25 | + | go func() { | |||
| 26 | + | stop := context.AfterFunc(ctx, func() { _ = listener.Close() }) | |||
| 27 | + | defer stop() | |||
| 28 | + | for { | |||
| 29 | + | conn, err := listener.AcceptUnix() | |||
| 30 | + | if err != nil { | |||
| 31 | + | return | |||
| 32 | + | } | |||
| 33 | + | // Serialize tiny bounded control requests. No unbounded per-peer workers. | |||
| 34 | + | func() { | |||
| 35 | + | defer conn.Close() | |||
| 36 | + | if !sameWebUID(conn) { | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | _ = conn.SetDeadline(time.Now().Add(time.Second)) | |||
| 40 | + | var command [1]byte | |||
| 41 | + | if _, err := io.ReadFull(conn, command[:]); err != nil { | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | var token string | |||
| 45 | + | switch command[0] { | |||
| 46 | + | case 'G': | |||
| 47 | + | token = server.Token() | |||
| 48 | + | case 'R': | |||
| 49 | + | token, err = server.RenewToken() | |||
| 50 | + | if err != nil { | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | default: | |||
| 54 | + | return | |||
| 55 | + | } | |||
| 56 | + | _ = json.NewEncoder(conn).Encode(webAccess{Token: token, Settings: settings}) | |||
| 57 | + | }() | |||
| 58 | + | } | |||
| 59 | + | }() | |||
| 60 | + | return listener, nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func webControlRequest(ctx context.Context, renew bool) (webAccess, error) { | |||
| 64 | + | ctx, cancel := context.WithTimeout(ctx, time.Second) | |||
| 65 | + | defer cancel() | |||
| 66 | + | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", webControlAddress()) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return webAccess{}, err | |||
| 69 | + | } | |||
| 70 | + | defer conn.Close() | |||
| 71 | + | unixConn, ok := conn.(*net.UnixConn) | |||
| 72 | + | if !ok { | |||
| 73 | + | return webAccess{}, errors.New("vev: unsafe web control peer") | |||
| 74 | + | } | |||
| 75 | + | if !sameWebUID(unixConn) { | |||
| 76 | + | return webAccess{}, errors.New("vev: unsafe web control peer") | |||
| 77 | + | } | |||
| 78 | + | deadline, _ := ctx.Deadline() | |||
| 79 | + | _ = conn.SetDeadline(deadline) | |||
| 80 | + | command := byte('G') | |||
| 81 | + | if renew { | |||
| 82 | + | command = 'R' | |||
| 83 | + | } | |||
| 84 | + | if _, err := conn.Write([]byte{command}); err != nil { | |||
| 85 | + | return webAccess{}, err | |||
| 86 | + | } | |||
| 87 | + | data, err := io.ReadAll(io.LimitReader(conn, 4097)) | |||
| 88 | + | if err != nil { | |||
| 89 | + | return webAccess{}, err | |||
| 90 | + | } | |||
| 91 | + | var access webAccess | |||
| 92 | + | if len(data) > 4096 || json.Unmarshal(data, &access) != nil { | |||
| 93 | + | return webAccess{}, errors.New("vev: invalid web control response") | |||
| 94 | + | } | |||
| 95 | + | decoded, err := base64.RawURLEncoding.DecodeString(access.Token) | |||
| 96 | + | settings, settingsErr := webterm.ParseSettings(access.Settings.Listen, access.Settings.Origin) | |||
| 97 | + | if err != nil || len(decoded) != 32 || settingsErr != nil || settings != access.Settings { | |||
| 98 | + | return webAccess{}, errors.New("vev: invalid web control response") | |||
| 99 | + | } | |||
| 100 | + | return access, nil | |||
| 101 | + | } | |||
A internal/app/web_control_darwin.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | //go:build darwin | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "net" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "syscall" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "golang.org/x/sys/unix" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/vev/internal/platform" | |
| 18 | + | "github.com/bnema/vev/pkg/safedir" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Darwin has no abstract Unix sockets, so the control interface binds a | |
| 22 | + | // 0600 socket file in a private per-user directory. Peer credentials, not | |
| 23 | + | // the discoverability of this path, protect the local control interface. | |
| 24 | + | func webControlAddress() string { | |
| 25 | + | path, _ := filepath.Abs(platform.StateDir()) | |
| 26 | + | sum := sha256.Sum256([]byte(path)) | |
| 27 | + | dir := filepath.Join(os.TempDir(), fmt.Sprintf("vev-web-%d", os.Getuid())) | |
| 28 | + | return filepath.Join(dir, fmt.Sprintf("%x.sock", sum[:8])) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func sameWebUID(conn *net.UnixConn) bool { | |
| 32 | + | raw, err := conn.SyscallConn() | |
| 33 | + | if err != nil { | |
| 34 | + | return false | |
| 35 | + | } | |
| 36 | + | var credential *unix.Xucred | |
| 37 | + | var peerErr error | |
| 38 | + | err = raw.Control(func(fd uintptr) { | |
| 39 | + | credential, peerErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) | |
| 40 | + | }) | |
| 41 | + | return err == nil && peerErr == nil && credential != nil && credential.Uid == uint32(os.Geteuid()) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func bindWebControl() (*net.UnixListener, error) { | |
| 45 | + | path := webControlAddress() | |
| 46 | + | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | |
| 47 | + | return nil, fmt.Errorf("vev: securing web control directory: %w", err) | |
| 48 | + | } | |
| 49 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) | |
| 50 | + | if err != nil { | |
| 51 | + | if !errors.Is(err, syscall.EADDRINUSE) { | |
| 52 | + | return nil, err | |
| 53 | + | } | |
| 54 | + | // Distinguish a live gateway from a stale socket left by a crash. | |
| 55 | + | probeConn, probeErr := net.DialTimeout("unix", path, time.Second) | |
| 56 | + | if probeErr == nil { | |
| 57 | + | _ = probeConn.Close() | |
| 58 | + | return nil, errors.New("vev: web gateway control is already bound") | |
| 59 | + | } | |
| 60 | + | if !errors.Is(probeErr, syscall.ECONNREFUSED) && !errors.Is(probeErr, syscall.ENOENT) { | |
| 61 | + | return nil, fmt.Errorf("vev: probing web control socket: %w", probeErr) | |
| 62 | + | } | |
| 63 | + | if rmErr := os.Remove(path); rmErr != nil && !os.IsNotExist(rmErr) { | |
| 64 | + | return nil, rmErr | |
| 65 | + | } | |
| 66 | + | listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) | |
| 67 | + | if err != nil { | |
| 68 | + | return nil, err | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | return listener, secureWebControlSocket(listener, path) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func secureWebControlSocket(listener *net.UnixListener, path string) error { | |
| 75 | + | if err := os.Chmod(path, 0o600); err != nil { | |
| 76 | + | _ = listener.Close() | |
| 77 | + | return fmt.Errorf("vev: securing web control socket: %w", err) | |
| 78 | + | } | |
| 79 | + | return nil | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | //go:build darwin | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "crypto/sha256" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "net" | |||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "syscall" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "golang.org/x/sys/unix" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/vev/internal/platform" | |||
| 18 | + | "github.com/bnema/vev/pkg/safedir" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // Darwin has no abstract Unix sockets, so the control interface binds a | |||
| 22 | + | // 0600 socket file in a private per-user directory. Peer credentials, not | |||
| 23 | + | // the discoverability of this path, protect the local control interface. | |||
| 24 | + | func webControlAddress() string { | |||
| 25 | + | path, _ := filepath.Abs(platform.StateDir()) | |||
| 26 | + | sum := sha256.Sum256([]byte(path)) | |||
| 27 | + | dir := filepath.Join(os.TempDir(), fmt.Sprintf("vev-web-%d", os.Getuid())) | |||
| 28 | + | return filepath.Join(dir, fmt.Sprintf("%x.sock", sum[:8])) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func sameWebUID(conn *net.UnixConn) bool { | |||
| 32 | + | raw, err := conn.SyscallConn() | |||
| 33 | + | if err != nil { | |||
| 34 | + | return false | |||
| 35 | + | } | |||
| 36 | + | var credential *unix.Xucred | |||
| 37 | + | var peerErr error | |||
| 38 | + | err = raw.Control(func(fd uintptr) { | |||
| 39 | + | credential, peerErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) | |||
| 40 | + | }) | |||
| 41 | + | return err == nil && peerErr == nil && credential != nil && credential.Uid == uint32(os.Geteuid()) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func bindWebControl() (*net.UnixListener, error) { | |||
| 45 | + | path := webControlAddress() | |||
| 46 | + | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | |||
| 47 | + | return nil, fmt.Errorf("vev: securing web control directory: %w", err) | |||
| 48 | + | } | |||
| 49 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) | |||
| 50 | + | if err != nil { | |||
| 51 | + | if !errors.Is(err, syscall.EADDRINUSE) { | |||
| 52 | + | return nil, err | |||
| 53 | + | } | |||
| 54 | + | // Distinguish a live gateway from a stale socket left by a crash. | |||
| 55 | + | probeConn, probeErr := net.DialTimeout("unix", path, time.Second) | |||
| 56 | + | if probeErr == nil { | |||
| 57 | + | _ = probeConn.Close() | |||
| 58 | + | return nil, errors.New("vev: web gateway control is already bound") | |||
| 59 | + | } | |||
| 60 | + | if !errors.Is(probeErr, syscall.ECONNREFUSED) && !errors.Is(probeErr, syscall.ENOENT) { | |||
| 61 | + | return nil, fmt.Errorf("vev: probing web control socket: %w", probeErr) | |||
| 62 | + | } | |||
| 63 | + | if rmErr := os.Remove(path); rmErr != nil && !os.IsNotExist(rmErr) { | |||
| 64 | + | return nil, rmErr | |||
| 65 | + | } | |||
| 66 | + | listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return nil, err | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | return listener, secureWebControlSocket(listener, path) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func secureWebControlSocket(listener *net.UnixListener, path string) error { | |||
| 75 | + | if err := os.Chmod(path, 0o600); err != nil { | |||
| 76 | + | _ = listener.Close() | |||
| 77 | + | return fmt.Errorf("vev: securing web control socket: %w", err) | |||
| 78 | + | } | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
A internal/app/web_control_linux.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "fmt" | |
| 8 | + | "net" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "syscall" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/platform" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Linux abstract sockets have no filesystem artifact. Peer credentials, not | |
| 17 | + | // the discoverability of this name, protect the local control interface. | |
| 18 | + | func webControlAddress() string { | |
| 19 | + | path, _ := filepath.Abs(platform.StateDir()) | |
| 20 | + | return fmt.Sprintf("@vev-web-%d-%x", os.Geteuid(), sha256.Sum256([]byte(path))) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func sameWebUID(conn *net.UnixConn) bool { | |
| 24 | + | raw, err := conn.SyscallConn() | |
| 25 | + | if err != nil { | |
| 26 | + | return false | |
| 27 | + | } | |
| 28 | + | var credential *syscall.Ucred | |
| 29 | + | var peerErr error | |
| 30 | + | err = raw.Control(func(fd uintptr) { | |
| 31 | + | credential, peerErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) | |
| 32 | + | }) | |
| 33 | + | return err == nil && peerErr == nil && credential != nil && credential.Uid == uint32(os.Geteuid()) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func bindWebControl() (*net.UnixListener, error) { | |
| 37 | + | return net.ListenUnix("unix", &net.UnixAddr{Name: webControlAddress(), Net: "unix"}) | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "crypto/sha256" | |||
| 7 | + | "fmt" | |||
| 8 | + | "net" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "syscall" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/platform" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Linux abstract sockets have no filesystem artifact. Peer credentials, not | |||
| 17 | + | // the discoverability of this name, protect the local control interface. | |||
| 18 | + | func webControlAddress() string { | |||
| 19 | + | path, _ := filepath.Abs(platform.StateDir()) | |||
| 20 | + | return fmt.Sprintf("@vev-web-%d-%x", os.Geteuid(), sha256.Sum256([]byte(path))) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func sameWebUID(conn *net.UnixConn) bool { | |||
| 24 | + | raw, err := conn.SyscallConn() | |||
| 25 | + | if err != nil { | |||
| 26 | + | return false | |||
| 27 | + | } | |||
| 28 | + | var credential *syscall.Ucred | |||
| 29 | + | var peerErr error | |||
| 30 | + | err = raw.Control(func(fd uintptr) { | |||
| 31 | + | credential, peerErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) | |||
| 32 | + | }) | |||
| 33 | + | return err == nil && peerErr == nil && credential != nil && credential.Uid == uint32(os.Geteuid()) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func bindWebControl() (*net.UnixListener, error) { | |||
| 37 | + | return net.ListenUnix("unix", &net.UnixAddr{Name: webControlAddress(), Net: "unix"}) | |||
| 38 | + | } | |||
A internal/app/web_daemon.go +235 -0
| @@ -0,0 +1,235 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "net" | |
| 8 | + | "net/http" | |
| 9 | + | "net/netip" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "os/signal" | |
| 13 | + | "strings" | |
| 14 | + | "syscall" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 18 | + | "github.com/bnema/vev/internal/adapters/webterm" | |
| 19 | + | "github.com/bnema/vev/internal/logging" | |
| 20 | + | "github.com/bnema/vev/internal/platform" | |
| 21 | + | "github.com/bnema/vev/internal/ports" | |
| 22 | + | "github.com/bnema/vev/internal/protocol" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | const webStartupTimeout = 10 * time.Second | |
| 26 | + | ||
| 27 | + | func webReachable(ctx context.Context, client *http.Client, access webAccess, expected webterm.Settings) bool { | |
| 28 | + | if access.Settings != expected { | |
| 29 | + | return false | |
| 30 | + | } | |
| 31 | + | request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+access.Settings.ProbeAddress()+"/health", nil) | |
| 32 | + | if err != nil { | |
| 33 | + | return false | |
| 34 | + | } | |
| 35 | + | request.Host = access.Settings.Host() | |
| 36 | + | request.AddCookie(&http.Cookie{Name: "vev-web-session", Value: access.Token}) | |
| 37 | + | response, err := client.Do(request) | |
| 38 | + | if err != nil { | |
| 39 | + | return false | |
| 40 | + | } | |
| 41 | + | defer response.Body.Close() | |
| 42 | + | return response.StatusCode == http.StatusNoContent | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func printWebLink(access webAccess) { | |
| 46 | + | fmt.Printf("vev web terminal: %s/#token=%s\nKeep this access link private.\n", access.Settings.Origin, access.Token) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func renewWebToken(ctx context.Context) error { | |
| 50 | + | token, err := webControlRequest(ctx, true) | |
| 51 | + | if err != nil { | |
| 52 | + | return fmt.Errorf("vev: renewing web token: %w", err) | |
| 53 | + | } | |
| 54 | + | printWebLink(token) | |
| 55 | + | return nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func launchWebDaemon(ctx context.Context, options webOptions) error { | |
| 59 | + | settings, warnings, err := loadWebSettings(options) | |
| 60 | + | if err != nil { | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | for _, warning := range warnings { | |
| 64 | + | if warning.Line > 0 { | |
| 65 | + | fmt.Fprintf(os.Stderr, "vev: config warning (line %d): %s\n", warning.Line, warning.Msg) | |
| 66 | + | continue | |
| 67 | + | } | |
| 68 | + | fmt.Fprintf(os.Stderr, "vev: config warning: %s\n", warning.Msg) | |
| 69 | + | } | |
| 70 | + | // One shared client for every readiness probe. Proxy stays disabled so | |
| 71 | + | // probes only ever contact the local listener. | |
| 72 | + | client := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} | |
| 73 | + | defer client.CloseIdleConnections() | |
| 74 | + | if token, err := webControlRequest(ctx, false); err == nil { | |
| 75 | + | if token.Settings != settings { | |
| 76 | + | return errors.New("vev: web gateway is running with different settings; stop and restart it to apply web.listen/web.origin") | |
| 77 | + | } | |
| 78 | + | readyCtx, cancel := context.WithTimeout(ctx, webStartupTimeout) | |
| 79 | + | defer cancel() | |
| 80 | + | ticker := time.NewTicker(50 * time.Millisecond) | |
| 81 | + | defer ticker.Stop() | |
| 82 | + | for !webReachable(readyCtx, client, token, settings) { | |
| 83 | + | select { | |
| 84 | + | case <-readyCtx.Done(): | |
| 85 | + | return fmt.Errorf("vev: existing web gateway not ready: %w", readyCtx.Err()) | |
| 86 | + | case <-ticker.C: | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | printWebLink(token) | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | listener, err := listenWeb(settings.Listen) | |
| 93 | + | if err != nil { | |
| 94 | + | return fmt.Errorf("vev: web port unavailable: %w", err) | |
| 95 | + | } | |
| 96 | + | defer listener.Close() | |
| 97 | + | inherited, ok := listener.(*net.TCPListener) | |
| 98 | + | if !ok { | |
| 99 | + | return fmt.Errorf("vev: web listener is not TCP") | |
| 100 | + | } | |
| 101 | + | inheritedFile, err := inherited.File() | |
| 102 | + | if err != nil { | |
| 103 | + | return err | |
| 104 | + | } | |
| 105 | + | defer inheritedFile.Close() | |
| 106 | + | executable, err := selfExePath() | |
| 107 | + | if err != nil { | |
| 108 | + | return err | |
| 109 | + | } | |
| 110 | + | null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 111 | + | if err != nil { | |
| 112 | + | return err | |
| 113 | + | } | |
| 114 | + | defer null.Close() | |
| 115 | + | child := exec.Command(executable, "--web-serve", "--web-listen", settings.Listen, "--web-origin", settings.Origin) | |
| 116 | + | // Browser capabilities do not depend on the launcher's TTY. | |
| 117 | + | child.Env = append(withoutPerformanceTraceEnv(os.Environ()), "TERM=xterm-256color", "COLORTERM=truecolor") | |
| 118 | + | child.Stdin, child.Stdout, child.Stderr = null, null, null | |
| 119 | + | child.ExtraFiles = []*os.File{inheritedFile} | |
| 120 | + | child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |
| 121 | + | if err := child.Start(); err != nil { | |
| 122 | + | return err | |
| 123 | + | } | |
| 124 | + | done := make(chan error, 1) | |
| 125 | + | go func() { done <- child.Wait() }() | |
| 126 | + | readyCtx, cancel := context.WithTimeout(ctx, webStartupTimeout) | |
| 127 | + | defer cancel() | |
| 128 | + | ticker := time.NewTicker(50 * time.Millisecond) | |
| 129 | + | defer ticker.Stop() | |
| 130 | + | for { | |
| 131 | + | token, err := webControlRequest(readyCtx, false) | |
| 132 | + | if err == nil && webReachable(readyCtx, client, token, settings) { | |
| 133 | + | printWebLink(token) | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | select { | |
| 137 | + | case err := <-done: | |
| 138 | + | if err != nil { | |
| 139 | + | return fmt.Errorf("vev: web process exited before readiness: %w", err) | |
| 140 | + | } | |
| 141 | + | return errors.New("vev: web process exited before readiness") | |
| 142 | + | case <-readyCtx.Done(): | |
| 143 | + | _ = child.Process.Kill() | |
| 144 | + | <-done | |
| 145 | + | return fmt.Errorf("vev: web startup: %w", readyCtx.Err()) | |
| 146 | + | case <-ticker.C: | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // Preserve the requested address family, including IPv4 wildcard listeners. | |
| 152 | + | func listenWeb(address string) (net.Listener, error) { | |
| 153 | + | addr, err := netip.ParseAddrPort(address) | |
| 154 | + | if err != nil { | |
| 155 | + | return nil, err | |
| 156 | + | } | |
| 157 | + | network := "tcp6" | |
| 158 | + | if addr.Addr().Is4() { | |
| 159 | + | network = "tcp4" | |
| 160 | + | } | |
| 161 | + | return net.Listen(network, address) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func runWebDaemon(parent context.Context, options webOptions) error { | |
| 165 | + | // The launcher passes resolved settings; do not reload a changing config. | |
| 166 | + | if options.listen == "" || options.origin == "" { | |
| 167 | + | return errors.New("vev: web server requires its launcher settings") | |
| 168 | + | } | |
| 169 | + | settings, err := webterm.ParseSettings(options.listen, options.origin) | |
| 170 | + | if err != nil { | |
| 171 | + | return err | |
| 172 | + | } | |
| 173 | + | ctx, cancel := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) | |
| 174 | + | defer cancel() | |
| 175 | + | inherited := os.NewFile(3, "web-listener") | |
| 176 | + | if inherited == nil { | |
| 177 | + | return errors.New("vev: web server requires its launcher") | |
| 178 | + | } | |
| 179 | + | listener, err := net.FileListener(inherited) | |
| 180 | + | _ = inherited.Close() | |
| 181 | + | if err != nil { | |
| 182 | + | return fmt.Errorf("vev: inherited web listener: %w", err) | |
| 183 | + | } | |
| 184 | + | defer listener.Close() | |
| 185 | + | if listener.Addr().String() != settings.Listen { | |
| 186 | + | return errors.New("vev: inherited web listener does not match web.listen") | |
| 187 | + | } | |
| 188 | + | token, err := webterm.NewToken() | |
| 189 | + | if err != nil { | |
| 190 | + | return err | |
| 191 | + | } | |
| 192 | + | log, closer, err := configureLogging(logging.Client, false) | |
| 193 | + | if err != nil { | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | defer closer.Close() | |
| 197 | + | handler, err := webterm.NewServer(ctx, settings, token, func(ctx context.Context, terminal *webterm.Terminal) error { | |
| 198 | + | clk := clock.New() | |
| 199 | + | deps := runAttachDeps{ | |
| 200 | + | terminal: func() ports.Terminal { return terminal }, clock: func() ports.Clock { return clk }, | |
| 201 | + | disableCapabilityProbe: true, selectedRemoteTransport: os.Getenv(envRemoteTransport), | |
| 202 | + | createDetached: createDetachedLocalSession, stateDir: platform.StateDir, | |
| 203 | + | } | |
| 204 | + | err := runAttachWithDeps(ctx, protocol.IntentEphemeral, "", "", "", log, deps) | |
| 205 | + | if err != nil && ctx.Err() == nil { | |
| 206 | + | log.Warn("web attachment ended", "error", strings.TrimSpace(err.Error())) | |
| 207 | + | } | |
| 208 | + | return err | |
| 209 | + | }) | |
| 210 | + | if err != nil { | |
| 211 | + | return err | |
| 212 | + | } | |
| 213 | + | control, err := startWebControl(ctx, handler, settings) | |
| 214 | + | if err != nil { | |
| 215 | + | return err | |
| 216 | + | } | |
| 217 | + | defer control.Close() | |
| 218 | + | server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10} | |
| 219 | + | done := make(chan struct{}) | |
| 220 | + | go func() { | |
| 221 | + | defer close(done) | |
| 222 | + | <-ctx.Done() | |
| 223 | + | shutdownCtx, stop := context.WithTimeout(context.Background(), 5*time.Second) | |
| 224 | + | defer stop() | |
| 225 | + | _ = server.Shutdown(shutdownCtx) | |
| 226 | + | }() | |
| 227 | + | err = server.Serve(listener) | |
| 228 | + | cancel() | |
| 229 | + | <-done | |
| 230 | + | handler.Wait() | |
| 231 | + | if errors.Is(err, http.ErrServerClosed) { | |
| 232 | + | return nil | |
| 233 | + | } | |
| 234 | + | return err | |
| 235 | + | } | |
| @@ -0,0 +1,235 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/netip" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "os/signal" | |||
| 13 | + | "strings" | |||
| 14 | + | "syscall" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/vev/internal/adapters/clock" | |||
| 18 | + | "github.com/bnema/vev/internal/adapters/webterm" | |||
| 19 | + | "github.com/bnema/vev/internal/logging" | |||
| 20 | + | "github.com/bnema/vev/internal/platform" | |||
| 21 | + | "github.com/bnema/vev/internal/ports" | |||
| 22 | + | "github.com/bnema/vev/internal/protocol" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | const webStartupTimeout = 10 * time.Second | |||
| 26 | + | ||||
| 27 | + | func webReachable(ctx context.Context, client *http.Client, access webAccess, expected webterm.Settings) bool { | |||
| 28 | + | if access.Settings != expected { | |||
| 29 | + | return false | |||
| 30 | + | } | |||
| 31 | + | request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+access.Settings.ProbeAddress()+"/health", nil) | |||
| 32 | + | if err != nil { | |||
| 33 | + | return false | |||
| 34 | + | } | |||
| 35 | + | request.Host = access.Settings.Host() | |||
| 36 | + | request.AddCookie(&http.Cookie{Name: "vev-web-session", Value: access.Token}) | |||
| 37 | + | response, err := client.Do(request) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return false | |||
| 40 | + | } | |||
| 41 | + | defer response.Body.Close() | |||
| 42 | + | return response.StatusCode == http.StatusNoContent | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func printWebLink(access webAccess) { | |||
| 46 | + | fmt.Printf("vev web terminal: %s/#token=%s\nKeep this access link private.\n", access.Settings.Origin, access.Token) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func renewWebToken(ctx context.Context) error { | |||
| 50 | + | token, err := webControlRequest(ctx, true) | |||
| 51 | + | if err != nil { | |||
| 52 | + | return fmt.Errorf("vev: renewing web token: %w", err) | |||
| 53 | + | } | |||
| 54 | + | printWebLink(token) | |||
| 55 | + | return nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func launchWebDaemon(ctx context.Context, options webOptions) error { | |||
| 59 | + | settings, warnings, err := loadWebSettings(options) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | for _, warning := range warnings { | |||
| 64 | + | if warning.Line > 0 { | |||
| 65 | + | fmt.Fprintf(os.Stderr, "vev: config warning (line %d): %s\n", warning.Line, warning.Msg) | |||
| 66 | + | continue | |||
| 67 | + | } | |||
| 68 | + | fmt.Fprintf(os.Stderr, "vev: config warning: %s\n", warning.Msg) | |||
| 69 | + | } | |||
| 70 | + | // One shared client for every readiness probe. Proxy stays disabled so | |||
| 71 | + | // probes only ever contact the local listener. | |||
| 72 | + | client := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} | |||
| 73 | + | defer client.CloseIdleConnections() | |||
| 74 | + | if token, err := webControlRequest(ctx, false); err == nil { | |||
| 75 | + | if token.Settings != settings { | |||
| 76 | + | return errors.New("vev: web gateway is running with different settings; stop and restart it to apply web.listen/web.origin") | |||
| 77 | + | } | |||
| 78 | + | readyCtx, cancel := context.WithTimeout(ctx, webStartupTimeout) | |||
| 79 | + | defer cancel() | |||
| 80 | + | ticker := time.NewTicker(50 * time.Millisecond) | |||
| 81 | + | defer ticker.Stop() | |||
| 82 | + | for !webReachable(readyCtx, client, token, settings) { | |||
| 83 | + | select { | |||
| 84 | + | case <-readyCtx.Done(): | |||
| 85 | + | return fmt.Errorf("vev: existing web gateway not ready: %w", readyCtx.Err()) | |||
| 86 | + | case <-ticker.C: | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | printWebLink(token) | |||
| 90 | + | return nil | |||
| 91 | + | } | |||
| 92 | + | listener, err := listenWeb(settings.Listen) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return fmt.Errorf("vev: web port unavailable: %w", err) | |||
| 95 | + | } | |||
| 96 | + | defer listener.Close() | |||
| 97 | + | inherited, ok := listener.(*net.TCPListener) | |||
| 98 | + | if !ok { | |||
| 99 | + | return fmt.Errorf("vev: web listener is not TCP") | |||
| 100 | + | } | |||
| 101 | + | inheritedFile, err := inherited.File() | |||
| 102 | + | if err != nil { | |||
| 103 | + | return err | |||
| 104 | + | } | |||
| 105 | + | defer inheritedFile.Close() | |||
| 106 | + | executable, err := selfExePath() | |||
| 107 | + | if err != nil { | |||
| 108 | + | return err | |||
| 109 | + | } | |||
| 110 | + | null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |||
| 111 | + | if err != nil { | |||
| 112 | + | return err | |||
| 113 | + | } | |||
| 114 | + | defer null.Close() | |||
| 115 | + | child := exec.Command(executable, "--web-serve", "--web-listen", settings.Listen, "--web-origin", settings.Origin) | |||
| 116 | + | // Browser capabilities do not depend on the launcher's TTY. | |||
| 117 | + | child.Env = append(withoutPerformanceTraceEnv(os.Environ()), "TERM=xterm-256color", "COLORTERM=truecolor") | |||
| 118 | + | child.Stdin, child.Stdout, child.Stderr = null, null, null | |||
| 119 | + | child.ExtraFiles = []*os.File{inheritedFile} | |||
| 120 | + | child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |||
| 121 | + | if err := child.Start(); err != nil { | |||
| 122 | + | return err | |||
| 123 | + | } | |||
| 124 | + | done := make(chan error, 1) | |||
| 125 | + | go func() { done <- child.Wait() }() | |||
| 126 | + | readyCtx, cancel := context.WithTimeout(ctx, webStartupTimeout) | |||
| 127 | + | defer cancel() | |||
| 128 | + | ticker := time.NewTicker(50 * time.Millisecond) | |||
| 129 | + | defer ticker.Stop() | |||
| 130 | + | for { | |||
| 131 | + | token, err := webControlRequest(readyCtx, false) | |||
| 132 | + | if err == nil && webReachable(readyCtx, client, token, settings) { | |||
| 133 | + | printWebLink(token) | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | select { | |||
| 137 | + | case err := <-done: | |||
| 138 | + | if err != nil { | |||
| 139 | + | return fmt.Errorf("vev: web process exited before readiness: %w", err) | |||
| 140 | + | } | |||
| 141 | + | return errors.New("vev: web process exited before readiness") | |||
| 142 | + | case <-readyCtx.Done(): | |||
| 143 | + | _ = child.Process.Kill() | |||
| 144 | + | <-done | |||
| 145 | + | return fmt.Errorf("vev: web startup: %w", readyCtx.Err()) | |||
| 146 | + | case <-ticker.C: | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // Preserve the requested address family, including IPv4 wildcard listeners. | |||
| 152 | + | func listenWeb(address string) (net.Listener, error) { | |||
| 153 | + | addr, err := netip.ParseAddrPort(address) | |||
| 154 | + | if err != nil { | |||
| 155 | + | return nil, err | |||
| 156 | + | } | |||
| 157 | + | network := "tcp6" | |||
| 158 | + | if addr.Addr().Is4() { | |||
| 159 | + | network = "tcp4" | |||
| 160 | + | } | |||
| 161 | + | return net.Listen(network, address) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func runWebDaemon(parent context.Context, options webOptions) error { | |||
| 165 | + | // The launcher passes resolved settings; do not reload a changing config. | |||
| 166 | + | if options.listen == "" || options.origin == "" { | |||
| 167 | + | return errors.New("vev: web server requires its launcher settings") | |||
| 168 | + | } | |||
| 169 | + | settings, err := webterm.ParseSettings(options.listen, options.origin) | |||
| 170 | + | if err != nil { | |||
| 171 | + | return err | |||
| 172 | + | } | |||
| 173 | + | ctx, cancel := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) | |||
| 174 | + | defer cancel() | |||
| 175 | + | inherited := os.NewFile(3, "web-listener") | |||
| 176 | + | if inherited == nil { | |||
| 177 | + | return errors.New("vev: web server requires its launcher") | |||
| 178 | + | } | |||
| 179 | + | listener, err := net.FileListener(inherited) | |||
| 180 | + | _ = inherited.Close() | |||
| 181 | + | if err != nil { | |||
| 182 | + | return fmt.Errorf("vev: inherited web listener: %w", err) | |||
| 183 | + | } | |||
| 184 | + | defer listener.Close() | |||
| 185 | + | if listener.Addr().String() != settings.Listen { | |||
| 186 | + | return errors.New("vev: inherited web listener does not match web.listen") | |||
| 187 | + | } | |||
| 188 | + | token, err := webterm.NewToken() | |||
| 189 | + | if err != nil { | |||
| 190 | + | return err | |||
| 191 | + | } | |||
| 192 | + | log, closer, err := configureLogging(logging.Client, false) | |||
| 193 | + | if err != nil { | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | defer closer.Close() | |||
| 197 | + | handler, err := webterm.NewServer(ctx, settings, token, func(ctx context.Context, terminal *webterm.Terminal) error { | |||
| 198 | + | clk := clock.New() | |||
| 199 | + | deps := runAttachDeps{ | |||
| 200 | + | terminal: func() ports.Terminal { return terminal }, clock: func() ports.Clock { return clk }, | |||
| 201 | + | disableCapabilityProbe: true, selectedRemoteTransport: os.Getenv(envRemoteTransport), | |||
| 202 | + | createDetached: createDetachedLocalSession, stateDir: platform.StateDir, | |||
| 203 | + | } | |||
| 204 | + | err := runAttachWithDeps(ctx, protocol.IntentEphemeral, "", "", "", log, deps) | |||
| 205 | + | if err != nil && ctx.Err() == nil { | |||
| 206 | + | log.Warn("web attachment ended", "error", strings.TrimSpace(err.Error())) | |||
| 207 | + | } | |||
| 208 | + | return err | |||
| 209 | + | }) | |||
| 210 | + | if err != nil { | |||
| 211 | + | return err | |||
| 212 | + | } | |||
| 213 | + | control, err := startWebControl(ctx, handler, settings) | |||
| 214 | + | if err != nil { | |||
| 215 | + | return err | |||
| 216 | + | } | |||
| 217 | + | defer control.Close() | |||
| 218 | + | server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10} | |||
| 219 | + | done := make(chan struct{}) | |||
| 220 | + | go func() { | |||
| 221 | + | defer close(done) | |||
| 222 | + | <-ctx.Done() | |||
| 223 | + | shutdownCtx, stop := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 224 | + | defer stop() | |||
| 225 | + | _ = server.Shutdown(shutdownCtx) | |||
| 226 | + | }() | |||
| 227 | + | err = server.Serve(listener) | |||
| 228 | + | cancel() | |||
| 229 | + | <-done | |||
| 230 | + | handler.Wait() | |||
| 231 | + | if errors.Is(err, http.ErrServerClosed) { | |||
| 232 | + | return nil | |||
| 233 | + | } | |||
| 234 | + | return err | |||
| 235 | + | } | |||
A internal/app/web_daemon_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/adapters/webterm" | |
| 9 | + | "github.com/bnema/vev/internal/platform" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestWebControlTokenLifetime(t *testing.T) { | |
| 14 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 15 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 16 | + | defer cancel() | |
| 17 | + | token, err := webterm.NewToken() | |
| 18 | + | require.NoError(t, err) | |
| 19 | + | settings, err := webterm.ParseSettings("", "https://terminal.example.internal") | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | server, err := webterm.NewServer(ctx, settings, token, func(context.Context, *webterm.Terminal) error { return nil }) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | listener, err := startWebControl(ctx, server, settings) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | defer listener.Close() | |
| 26 | + | got, err := webControlRequest(ctx, false) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | require.Equal(t, token, got.Token) | |
| 29 | + | require.Equal(t, settings, got.Settings) | |
| 30 | + | renewed, err := webControlRequest(ctx, true) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.NotEqual(t, token, renewed.Token) | |
| 33 | + | require.Equal(t, settings, renewed.Settings) | |
| 34 | + | got, err = webControlRequest(ctx, false) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | require.Equal(t, renewed, got) | |
| 37 | + | _, err = os.Stat(platform.StateDir()) | |
| 38 | + | require.True(t, os.IsNotExist(err), "control must not persist a credential") | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/adapters/webterm" | |||
| 9 | + | "github.com/bnema/vev/internal/platform" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestWebControlTokenLifetime(t *testing.T) { | |||
| 14 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 15 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 16 | + | defer cancel() | |||
| 17 | + | token, err := webterm.NewToken() | |||
| 18 | + | require.NoError(t, err) | |||
| 19 | + | settings, err := webterm.ParseSettings("", "https://terminal.example.internal") | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | server, err := webterm.NewServer(ctx, settings, token, func(context.Context, *webterm.Terminal) error { return nil }) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | listener, err := startWebControl(ctx, server, settings) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | defer listener.Close() | |||
| 26 | + | got, err := webControlRequest(ctx, false) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | require.Equal(t, token, got.Token) | |||
| 29 | + | require.Equal(t, settings, got.Settings) | |||
| 30 | + | renewed, err := webControlRequest(ctx, true) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | require.NotEqual(t, token, renewed.Token) | |||
| 33 | + | require.Equal(t, settings, renewed.Settings) | |||
| 34 | + | got, err = webControlRequest(ctx, false) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | require.Equal(t, renewed, got) | |||
| 37 | + | _, err = os.Stat(platform.StateDir()) | |||
| 38 | + | require.True(t, os.IsNotExist(err), "control must not persist a credential") | |||
| 39 | + | } | |||
A internal/app/web_listener_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "net" | |
| 6 | + | "strings" | |
| 7 | + | "syscall" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestWebListenerInheritancePreservesFamily(t *testing.T) { | |
| 14 | + | for _, host := range []string{"127.0.0.1", "0.0.0.0", "::1", "::"} { | |
| 15 | + | t.Run(host, func(t *testing.T) { | |
| 16 | + | listener, err := listenWeb(net.JoinHostPort(host, "0")) | |
| 17 | + | if strings.Contains(host, ":") && (errors.Is(err, syscall.EADDRNOTAVAIL) || errors.Is(err, syscall.EAFNOSUPPORT)) { | |
| 18 | + | t.Skip("IPv6 address unavailable on this host") | |
| 19 | + | } | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | defer listener.Close() | |
| 22 | + | gotHost, _, err := net.SplitHostPort(listener.Addr().String()) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | require.Equal(t, host, gotHost) | |
| 25 | + | file, err := listener.(*net.TCPListener).File() | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | defer file.Close() | |
| 28 | + | inherited, err := net.FileListener(file) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | defer inherited.Close() | |
| 31 | + | require.Equal(t, listener.Addr().String(), inherited.Addr().String()) | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "net" | |||
| 6 | + | "strings" | |||
| 7 | + | "syscall" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestWebListenerInheritancePreservesFamily(t *testing.T) { | |||
| 14 | + | for _, host := range []string{"127.0.0.1", "0.0.0.0", "::1", "::"} { | |||
| 15 | + | t.Run(host, func(t *testing.T) { | |||
| 16 | + | listener, err := listenWeb(net.JoinHostPort(host, "0")) | |||
| 17 | + | if strings.Contains(host, ":") && (errors.Is(err, syscall.EADDRNOTAVAIL) || errors.Is(err, syscall.EAFNOSUPPORT)) { | |||
| 18 | + | t.Skip("IPv6 address unavailable on this host") | |||
| 19 | + | } | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | defer listener.Close() | |||
| 22 | + | gotHost, _, err := net.SplitHostPort(listener.Addr().String()) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | require.Equal(t, host, gotHost) | |||
| 25 | + | file, err := listener.(*net.TCPListener).File() | |||
| 26 | + | require.NoError(t, err) | |||
| 27 | + | defer file.Close() | |||
| 28 | + | inherited, err := net.FileListener(file) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | defer inherited.Close() | |||
| 31 | + | require.Equal(t, listener.Addr().String(), inherited.Addr().String()) | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
A internal/app/web_options.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/adapters/config" | |
| 8 | + | "github.com/bnema/vev/internal/adapters/webterm" | |
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/platform" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type webOptions struct { | |
| 14 | + | listen string | |
| 15 | + | origin string | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func parseWebArgs(args []string) (command, error) { | |
| 19 | + | cmd := command{kind: kindWebDaemon} | |
| 20 | + | if args[0] == "--web-serve" { | |
| 21 | + | cmd.kind = kindWebServe | |
| 22 | + | } | |
| 23 | + | for i := 1; i < len(args); i++ { | |
| 24 | + | key, value, inline := strings.Cut(args[i], "=") | |
| 25 | + | if key != "--web-listen" && key != "--web-origin" { | |
| 26 | + | return command{}, usagef("unexpected web argument %q", key) | |
| 27 | + | } | |
| 28 | + | if !inline { | |
| 29 | + | i++ | |
| 30 | + | if i == len(args) { | |
| 31 | + | return command{}, usagef("%s requires a value", key) | |
| 32 | + | } | |
| 33 | + | value = args[i] | |
| 34 | + | } | |
| 35 | + | if value == "" || strings.HasPrefix(value, "--") { | |
| 36 | + | return command{}, usagef("%s requires a value", key) | |
| 37 | + | } | |
| 38 | + | if key == "--web-listen" { | |
| 39 | + | cmd.web.listen = value | |
| 40 | + | } else { | |
| 41 | + | cmd.web.origin = value | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | return cmd, nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func loadWebSettings(options webOptions) (webterm.Settings, []domain.Warning, error) { | |
| 48 | + | cfg, warnings, err := config.Load(platform.ConfigPath()) | |
| 49 | + | if err != nil { | |
| 50 | + | return webterm.Settings{}, nil, fmt.Errorf("vev: loading web configuration: %w", err) | |
| 51 | + | } | |
| 52 | + | if options.listen != "" { | |
| 53 | + | cfg.WebListen = options.listen | |
| 54 | + | } | |
| 55 | + | if options.origin != "" { | |
| 56 | + | cfg.WebOrigin = options.origin | |
| 57 | + | } | |
| 58 | + | settings, err := webterm.ParseSettings(cfg.WebListen, cfg.WebOrigin) | |
| 59 | + | if err != nil { | |
| 60 | + | return webterm.Settings{}, warnings, err | |
| 61 | + | } | |
| 62 | + | return settings, warnings, nil | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/adapters/config" | |||
| 8 | + | "github.com/bnema/vev/internal/adapters/webterm" | |||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/platform" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type webOptions struct { | |||
| 14 | + | listen string | |||
| 15 | + | origin string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func parseWebArgs(args []string) (command, error) { | |||
| 19 | + | cmd := command{kind: kindWebDaemon} | |||
| 20 | + | if args[0] == "--web-serve" { | |||
| 21 | + | cmd.kind = kindWebServe | |||
| 22 | + | } | |||
| 23 | + | for i := 1; i < len(args); i++ { | |||
| 24 | + | key, value, inline := strings.Cut(args[i], "=") | |||
| 25 | + | if key != "--web-listen" && key != "--web-origin" { | |||
| 26 | + | return command{}, usagef("unexpected web argument %q", key) | |||
| 27 | + | } | |||
| 28 | + | if !inline { | |||
| 29 | + | i++ | |||
| 30 | + | if i == len(args) { | |||
| 31 | + | return command{}, usagef("%s requires a value", key) | |||
| 32 | + | } | |||
| 33 | + | value = args[i] | |||
| 34 | + | } | |||
| 35 | + | if value == "" || strings.HasPrefix(value, "--") { | |||
| 36 | + | return command{}, usagef("%s requires a value", key) | |||
| 37 | + | } | |||
| 38 | + | if key == "--web-listen" { | |||
| 39 | + | cmd.web.listen = value | |||
| 40 | + | } else { | |||
| 41 | + | cmd.web.origin = value | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | return cmd, nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func loadWebSettings(options webOptions) (webterm.Settings, []domain.Warning, error) { | |||
| 48 | + | cfg, warnings, err := config.Load(platform.ConfigPath()) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return webterm.Settings{}, nil, fmt.Errorf("vev: loading web configuration: %w", err) | |||
| 51 | + | } | |||
| 52 | + | if options.listen != "" { | |||
| 53 | + | cfg.WebListen = options.listen | |||
| 54 | + | } | |||
| 55 | + | if options.origin != "" { | |||
| 56 | + | cfg.WebOrigin = options.origin | |||
| 57 | + | } | |||
| 58 | + | settings, err := webterm.ParseSettings(cfg.WebListen, cfg.WebOrigin) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return webterm.Settings{}, warnings, err | |||
| 61 | + | } | |||
| 62 | + | return settings, warnings, nil | |||
| 63 | + | } | |||
A internal/app/web_options_test.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/http/httptest" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/adapters/webterm" | |
| 12 | + | "github.com/bnema/vev/pkg/safedir" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestWebFlags(t *testing.T) { | |
| 17 | + | for _, tt := range []struct { | |
| 18 | + | args []string | |
| 19 | + | want webOptions | |
| 20 | + | fail bool | |
| 21 | + | }{ | |
| 22 | + | {[]string{"--web-daemon"}, webOptions{}, false}, | |
| 23 | + | {[]string{"--web-daemon", "--web-listen", "127.0.0.1:9000", "--web-origin=https://terminal.example.internal"}, webOptions{"127.0.0.1:9000", "https://terminal.example.internal"}, false}, | |
| 24 | + | {[]string{"--web-daemon", "--web-listen"}, webOptions{}, true}, | |
| 25 | + | {[]string{"--web-daemon", "--web-origin="}, webOptions{}, true}, | |
| 26 | + | {[]string{"--web-daemon", "--web-listen", "--web-origin=x"}, webOptions{}, true}, | |
| 27 | + | {[]string{"--web-daemon", "extra"}, webOptions{}, true}, | |
| 28 | + | {[]string{"--web-renew-token", "--web-origin=x"}, webOptions{}, true}, | |
| 29 | + | } { | |
| 30 | + | t.Run(strings.Join(tt.args, " "), func(t *testing.T) { | |
| 31 | + | got, err := parseArgs(tt.args) | |
| 32 | + | if tt.fail { | |
| 33 | + | require.Error(t, err) | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | require.Equal(t, kindWebDaemon, got.kind) | |
| 38 | + | require.Equal(t, tt.want, got.web) | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestWebConfigPrecedence(t *testing.T) { | |
| 44 | + | root := t.TempDir() | |
| 45 | + | t.Setenv("XDG_CONFIG_HOME", root) | |
| 46 | + | dir := filepath.Join(root, "vev") | |
| 47 | + | require.NoError(t, safedir.EnsurePrivate(dir)) | |
| 48 | + | path := filepath.Join(dir, "config") | |
| 49 | + | got, _, err := loadWebSettings(webOptions{}) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | require.Equal(t, webterm.Settings{Listen: webterm.Address, Origin: webterm.Origin}, got) | |
| 52 | + | for _, tt := range []struct { | |
| 53 | + | name, config string | |
| 54 | + | flags webOptions | |
| 55 | + | listen, origin string | |
| 56 | + | fail bool | |
| 57 | + | }{ | |
| 58 | + | {"persisted", "web.listen = 127.0.0.1:9000\nweb.origin = https://terminal.example.internal\n", webOptions{}, "127.0.0.1:9000", "https://terminal.example.internal", false}, | |
| 59 | + | {"flags", "web.listen = invalid\nweb.origin = invalid\n", webOptions{"127.0.0.1:9001", "http://localhost:9001"}, "127.0.0.1:9001", "http://localhost:9001", false}, | |
| 60 | + | {"invalid config", "web.listen = invalid\n", webOptions{}, "", "", true}, | |
| 61 | + | {"explicit exposure", "web.listen = 0.0.0.0:9000\n", webOptions{}, "", "", true}, | |
| 62 | + | {"config warnings propagate", "web.listen = 127.0.0.1:9000\nweb.origin = https://terminal.example.internal\nbadline\n", webOptions{}, "127.0.0.1:9000", "https://terminal.example.internal", false}, | |
| 63 | + | } { | |
| 64 | + | t.Run(tt.name, func(t *testing.T) { | |
| 65 | + | require.NoError(t, os.WriteFile(path, []byte(tt.config), 0600)) | |
| 66 | + | got, warnings, err := loadWebSettings(tt.flags) | |
| 67 | + | if tt.fail { | |
| 68 | + | require.Error(t, err) | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | require.Equal(t, webterm.Settings{Listen: tt.listen, Origin: tt.origin}, got) | |
| 73 | + | if tt.name == "config warnings propagate" { | |
| 74 | + | require.NotEmpty(t, warnings, "config warnings must reach the web launcher") | |
| 75 | + | } | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestWebReadinessUsesLocalListener(t *testing.T) { | |
| 81 | + | token, err := webterm.NewToken() | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | settings, err := webterm.ParseSettings("", "https://unresolvable.example.invalid") | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | handler, err := webterm.NewServer(t.Context(), settings, token, func(context.Context, *webterm.Terminal) error { return nil }) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | server := httptest.NewServer(handler) | |
| 88 | + | defer server.Close() | |
| 89 | + | settings.Listen = server.Listener.Addr().String() | |
| 90 | + | access := webAccess{Token: token, Settings: settings} | |
| 91 | + | client := server.Client() | |
| 92 | + | t.Setenv("HTTP_PROXY", "http://unresolvable.example.invalid") | |
| 93 | + | require.True(t, webReachable(t.Context(), client, access, settings)) | |
| 94 | + | other := settings | |
| 95 | + | other.Origin = "https://other.example.invalid" | |
| 96 | + | require.False(t, webReachable(t.Context(), client, access, other), "a healthy different gateway must not satisfy readiness") | |
| 97 | + | other = settings | |
| 98 | + | other.Listen = "127.0.0.1:1" | |
| 99 | + | require.False(t, webReachable(t.Context(), client, access, other), "the requested listener must match too") | |
| 100 | + | access.Token = "wrong" | |
| 101 | + | require.False(t, webReachable(t.Context(), client, access, settings)) | |
| 102 | + | } | |
| @@ -0,0 +1,102 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http/httptest" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/adapters/webterm" | |||
| 12 | + | "github.com/bnema/vev/pkg/safedir" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestWebFlags(t *testing.T) { | |||
| 17 | + | for _, tt := range []struct { | |||
| 18 | + | args []string | |||
| 19 | + | want webOptions | |||
| 20 | + | fail bool | |||
| 21 | + | }{ | |||
| 22 | + | {[]string{"--web-daemon"}, webOptions{}, false}, | |||
| 23 | + | {[]string{"--web-daemon", "--web-listen", "127.0.0.1:9000", "--web-origin=https://terminal.example.internal"}, webOptions{"127.0.0.1:9000", "https://terminal.example.internal"}, false}, | |||
| 24 | + | {[]string{"--web-daemon", "--web-listen"}, webOptions{}, true}, | |||
| 25 | + | {[]string{"--web-daemon", "--web-origin="}, webOptions{}, true}, | |||
| 26 | + | {[]string{"--web-daemon", "--web-listen", "--web-origin=x"}, webOptions{}, true}, | |||
| 27 | + | {[]string{"--web-daemon", "extra"}, webOptions{}, true}, | |||
| 28 | + | {[]string{"--web-renew-token", "--web-origin=x"}, webOptions{}, true}, | |||
| 29 | + | } { | |||
| 30 | + | t.Run(strings.Join(tt.args, " "), func(t *testing.T) { | |||
| 31 | + | got, err := parseArgs(tt.args) | |||
| 32 | + | if tt.fail { | |||
| 33 | + | require.Error(t, err) | |||
| 34 | + | return | |||
| 35 | + | } | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | require.Equal(t, kindWebDaemon, got.kind) | |||
| 38 | + | require.Equal(t, tt.want, got.web) | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestWebConfigPrecedence(t *testing.T) { | |||
| 44 | + | root := t.TempDir() | |||
| 45 | + | t.Setenv("XDG_CONFIG_HOME", root) | |||
| 46 | + | dir := filepath.Join(root, "vev") | |||
| 47 | + | require.NoError(t, safedir.EnsurePrivate(dir)) | |||
| 48 | + | path := filepath.Join(dir, "config") | |||
| 49 | + | got, _, err := loadWebSettings(webOptions{}) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | require.Equal(t, webterm.Settings{Listen: webterm.Address, Origin: webterm.Origin}, got) | |||
| 52 | + | for _, tt := range []struct { | |||
| 53 | + | name, config string | |||
| 54 | + | flags webOptions | |||
| 55 | + | listen, origin string | |||
| 56 | + | fail bool | |||
| 57 | + | }{ | |||
| 58 | + | {"persisted", "web.listen = 127.0.0.1:9000\nweb.origin = https://terminal.example.internal\n", webOptions{}, "127.0.0.1:9000", "https://terminal.example.internal", false}, | |||
| 59 | + | {"flags", "web.listen = invalid\nweb.origin = invalid\n", webOptions{"127.0.0.1:9001", "http://localhost:9001"}, "127.0.0.1:9001", "http://localhost:9001", false}, | |||
| 60 | + | {"invalid config", "web.listen = invalid\n", webOptions{}, "", "", true}, | |||
| 61 | + | {"explicit exposure", "web.listen = 0.0.0.0:9000\n", webOptions{}, "", "", true}, | |||
| 62 | + | {"config warnings propagate", "web.listen = 127.0.0.1:9000\nweb.origin = https://terminal.example.internal\nbadline\n", webOptions{}, "127.0.0.1:9000", "https://terminal.example.internal", false}, | |||
| 63 | + | } { | |||
| 64 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 65 | + | require.NoError(t, os.WriteFile(path, []byte(tt.config), 0600)) | |||
| 66 | + | got, warnings, err := loadWebSettings(tt.flags) | |||
| 67 | + | if tt.fail { | |||
| 68 | + | require.Error(t, err) | |||
| 69 | + | return | |||
| 70 | + | } | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | require.Equal(t, webterm.Settings{Listen: tt.listen, Origin: tt.origin}, got) | |||
| 73 | + | if tt.name == "config warnings propagate" { | |||
| 74 | + | require.NotEmpty(t, warnings, "config warnings must reach the web launcher") | |||
| 75 | + | } | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestWebReadinessUsesLocalListener(t *testing.T) { | |||
| 81 | + | token, err := webterm.NewToken() | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | settings, err := webterm.ParseSettings("", "https://unresolvable.example.invalid") | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | handler, err := webterm.NewServer(t.Context(), settings, token, func(context.Context, *webterm.Terminal) error { return nil }) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | server := httptest.NewServer(handler) | |||
| 88 | + | defer server.Close() | |||
| 89 | + | settings.Listen = server.Listener.Addr().String() | |||
| 90 | + | access := webAccess{Token: token, Settings: settings} | |||
| 91 | + | client := server.Client() | |||
| 92 | + | t.Setenv("HTTP_PROXY", "http://unresolvable.example.invalid") | |||
| 93 | + | require.True(t, webReachable(t.Context(), client, access, settings)) | |||
| 94 | + | other := settings | |||
| 95 | + | other.Origin = "https://other.example.invalid" | |||
| 96 | + | require.False(t, webReachable(t.Context(), client, access, other), "a healthy different gateway must not satisfy readiness") | |||
| 97 | + | other = settings | |||
| 98 | + | other.Listen = "127.0.0.1:1" | |||
| 99 | + | require.False(t, webReachable(t.Context(), client, access, other), "the requested listener must match too") | |||
| 100 | + | access.Token = "wrong" | |||
| 101 | + | require.False(t, webReachable(t.Context(), client, access, settings)) | |||
| 102 | + | } | |||
M internal/domain/config.go +2 -0
| @@ -108,6 +108,8 @@ func (c ScrollbackConfig) Valid() bool { | |||
| 108 | 108 | // keys are preserved here (in BindingEntries, in file order) so the usecase | |
| 109 | 109 | // layer can decide which actions it understands. | |
| 110 | 110 | type Config struct { | |
| 111 | + | WebListen string | |
| 112 | + | WebOrigin string | |
| 111 | 113 | Theme ThemeMode | |
| 112 | 114 | ThemePalette bool | |
| 113 | 115 | ThemeAccent ThemeAccent | |
| @@ -108,6 +108,8 @@ func (c ScrollbackConfig) Valid() bool { | |||||
| 108 | // keys are preserved here (in BindingEntries, in file order) so the usecase | 108 | // keys are preserved here (in BindingEntries, in file order) so the usecase | ||
| 109 | // layer can decide which actions it understands. | 109 | // layer can decide which actions it understands. | ||
| 110 | type Config struct { | 110 | type Config struct { | ||
| 111 | + | WebListen string | |||
| 112 | + | WebOrigin string | |||
| 111 | Theme ThemeMode | 113 | Theme ThemeMode | ||
| 112 | ThemePalette bool | 114 | ThemePalette bool | ||
| 113 | ThemeAccent ThemeAccent | 115 | ThemeAccent ThemeAccent | ||
A internal/usecase/theme/neutral_history_test.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestNeutralHistoryGradient(t *testing.T) { | |
| 11 | + | theme := Theme{Foreground: renderer.RGB{R: 216, G: 216, B: 216}, Background: renderer.RGB{R: 16, G: 16, B: 16}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 12 | + | styles := NewStyles(theme) | |
| 13 | + | require.Equal(t, theme.Foreground, styles.TabActive.BackgroundRGB) | |
| 14 | + | for count := 1; count <= 9; count++ { | |
| 15 | + | previous := styles.TabActive.BackgroundRGB.R | |
| 16 | + | for index := 0; index < count; index++ { | |
| 17 | + | style := styles.MRUStyle(index, count) | |
| 18 | + | require.Less(t, style.BackgroundRGB.R, previous) | |
| 19 | + | previous = style.BackgroundRGB.R | |
| 20 | + | } | |
| 21 | + | require.Equal(t, styles.SurfaceBar.BackgroundRGB, styles.MRUStyle(count-1, count).BackgroundRGB) | |
| 22 | + | } | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestNeutralHistoryGradient(t *testing.T) { | |||
| 11 | + | theme := Theme{Foreground: renderer.RGB{R: 216, G: 216, B: 216}, Background: renderer.RGB{R: 16, G: 16, B: 16}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |||
| 12 | + | styles := NewStyles(theme) | |||
| 13 | + | require.Equal(t, theme.Foreground, styles.TabActive.BackgroundRGB) | |||
| 14 | + | for count := 1; count <= 9; count++ { | |||
| 15 | + | previous := styles.TabActive.BackgroundRGB.R | |||
| 16 | + | for index := 0; index < count; index++ { | |||
| 17 | + | style := styles.MRUStyle(index, count) | |||
| 18 | + | require.Less(t, style.BackgroundRGB.R, previous) | |||
| 19 | + | previous = style.BackgroundRGB.R | |||
| 20 | + | } | |||
| 21 | + | require.Equal(t, styles.SurfaceBar.BackgroundRGB, styles.MRUStyle(count-1, count).BackgroundRGB) | |||
| 22 | + | } | |||
| 23 | + | } | |||
M internal/usecase/theme/phase3_review_test.go +2 -0
| @@ -121,6 +121,8 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) { | |||
| 121 | 121 | // PickerSeparator is now a semantic role with the SurfaceBar | |
| 122 | 122 | // background; it was never a compatibility alias. | |
| 123 | 123 | delete(want, "PickerSeparator") | |
| 124 | + | // Active neutral tab names use a high-contrast history anchor. | |
| 125 | + | want["TabNameActive"] = EmphasisStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme) | |
| 124 | 126 | for name, style := range want { | |
| 125 | 127 | require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name) | |
| 126 | 128 | } | |
| @@ -121,6 +121,8 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) { | |||||
| 121 | // PickerSeparator is now a semantic role with the SurfaceBar | 121 | // PickerSeparator is now a semantic role with the SurfaceBar | ||
| 122 | // background; it was never a compatibility alias. | 122 | // background; it was never a compatibility alias. | ||
| 123 | delete(want, "PickerSeparator") | 123 | delete(want, "PickerSeparator") | ||
| 124 | + | // Active neutral tab names use a high-contrast history anchor. | |||
| 125 | + | want["TabNameActive"] = EmphasisStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme) | |||
| 124 | for name, style := range want { | 126 | for name, style := range want { | ||
| 125 | require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name) | 127 | require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name) | ||
| 126 | } | 128 | } | ||
M internal/usecase/theme/resolved_styles.go +19 -3
| @@ -160,9 +160,25 @@ func neutralStyles(t Theme) Styles { | |||
| 160 | 160 | // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy | |
| 161 | 161 | // foreground-only bytes; rendered palette descriptions use PickerDescription. | |
| 162 | 162 | styles.PaletteDesc = legacyMutedText(t) | |
| 163 | - | // Neutral and indexed fallbacks retain their existing non-RGB hierarchy; | |
| 164 | - | // MRU entries deliberately share the neutral recent surface. | |
| 165 | - | return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) | |
| 163 | + | styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) | |
| 164 | + | if usable(t) { | |
| 165 | + | styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t) | |
| 166 | + | styles.TabNameActive = styles.TabActive | |
| 167 | + | // Keep the neutral history ordered visually, even without a palette | |
| 168 | + | // accent: recent entries fade into the bar as their rank increases. | |
| 169 | + | for count := 1; count <= len(styles.mruStyles); count++ { | |
| 170 | + | for index := range count { | |
| 171 | + | amount := float64(index+1) / float64(count) | |
| 172 | + | background := Blend(t.Foreground, status.BackgroundRGB, amount) | |
| 173 | + | foreground, ok := primaryText(t, background) | |
| 174 | + | if !ok { | |
| 175 | + | foreground, _ = primaryText(Theme{Foreground: renderer.RGB{R: 255, G: 255, B: 255}}, background) | |
| 176 | + | } | |
| 177 | + | styles.mruStyles[count-1][index] = rgbSurface(foreground, background) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | return styles | |
| 166 | 182 | } | |
| 167 | 183 | ||
| 168 | 184 | func withMRUStyles(styles Styles, ramp Ramp) Styles { | |
| @@ -160,9 +160,25 @@ func neutralStyles(t Theme) Styles { | |||||
| 160 | // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy | 160 | // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy | ||
| 161 | // foreground-only bytes; rendered palette descriptions use PickerDescription. | 161 | // foreground-only bytes; rendered palette descriptions use PickerDescription. | ||
| 162 | styles.PaletteDesc = legacyMutedText(t) | 162 | styles.PaletteDesc = legacyMutedText(t) | ||
| 163 | - | // Neutral and indexed fallbacks retain their existing non-RGB hierarchy; | 163 | + | styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) |
| 164 | - | // MRU entries deliberately share the neutral recent surface. | 164 | + | if usable(t) { |
| 165 | - | return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) | 165 | + | styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t) |
| 166 | + | styles.TabNameActive = styles.TabActive | |||
| 167 | + | // Keep the neutral history ordered visually, even without a palette | |||
| 168 | + | // accent: recent entries fade into the bar as their rank increases. | |||
| 169 | + | for count := 1; count <= len(styles.mruStyles); count++ { | |||
| 170 | + | for index := range count { | |||
| 171 | + | amount := float64(index+1) / float64(count) | |||
| 172 | + | background := Blend(t.Foreground, status.BackgroundRGB, amount) | |||
| 173 | + | foreground, ok := primaryText(t, background) | |||
| 174 | + | if !ok { | |||
| 175 | + | foreground, _ = primaryText(Theme{Foreground: renderer.RGB{R: 255, G: 255, B: 255}}, background) | |||
| 176 | + | } | |||
| 177 | + | styles.mruStyles[count-1][index] = rgbSurface(foreground, background) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | return styles | |||
| 166 | } | 182 | } | ||
| 167 | 183 | ||||
| 168 | func withMRUStyles(styles Styles, ramp Ramp) Styles { | 184 | func withMRUStyles(styles Styles, ramp Ramp) Styles { | ||
A scripts/web-access-smoke.cjs +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | // Isolated profile only: rotates access and disconnects all browser views. | |
| 2 | + | const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); | |
| 3 | + | const { execFileSync } = require('node:child_process'); | |
| 4 | + | const assert = require('node:assert/strict'); | |
| 5 | + | (async () => { | |
| 6 | + | if (!process.env.VEV_BINARY || !process.env.VEV_ENV) throw new Error('VEV_BINARY and isolated VEV_ENV are required'); | |
| 7 | + | const link = flag => execFileSync(process.env.VEV_BINARY, [flag], { encoding: 'utf8' }).match(/http:\/\/127\.0\.0\.1:8778\/#token=[A-Za-z0-9_-]+/)[0]; | |
| 8 | + | const first = link('--web-daemon'); | |
| 9 | + | assert.equal(link('--web-daemon'), first); | |
| 10 | + | const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH }); | |
| 11 | + | try { | |
| 12 | + | const page = await browser.newPage(); | |
| 13 | + | await page.goto(first); | |
| 14 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |
| 15 | + | await page.waitForFunction(() => Number(document.querySelector('#view-count').textContent) >= 1); | |
| 16 | + | const initial = Number(await page.locator('#view-count').textContent()); | |
| 17 | + | assert.equal(await page.locator('#view-description').textContent(), `Connected browser terminal views: ${initial}`); | |
| 18 | + | const button = page.locator('#palette'); | |
| 19 | + | const normal = await button.evaluate(node => ({ color: getComputedStyle(node).color, background: getComputedStyle(node).backgroundColor, border: getComputedStyle(node).borderTopWidth })); | |
| 20 | + | await button.hover(); | |
| 21 | + | const hover = await button.evaluate(node => ({ color: getComputedStyle(node).color, background: getComputedStyle(node).backgroundColor })); | |
| 22 | + | assert.equal(normal.border, '0px'); | |
| 23 | + | assert.equal(hover.background, normal.background); | |
| 24 | + | assert.notEqual(hover.color, normal.color); | |
| 25 | + | const other = await browser.newPage(); | |
| 26 | + | await other.goto(first); | |
| 27 | + | await other.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |
| 28 | + | await page.waitForFunction(n => Number(document.querySelector('#view-count').textContent) === n + 1, initial); | |
| 29 | + | assert.equal(await page.locator('#connection').getAttribute('data-state'), 'connected'); | |
| 30 | + | await other.close(); | |
| 31 | + | await page.waitForFunction(n => Number(document.querySelector('#view-count').textContent) === n, initial); | |
| 32 | + | const next = link('--web-renew-token'); | |
| 33 | + | assert.notEqual(next, first); | |
| 34 | + | await page.waitForFunction(() => document.querySelector('#status').textContent.startsWith('Disconnected')); | |
| 35 | + | const oldStatus = await page.evaluate(async () => (await fetch('/health')).status); | |
| 36 | + | assert.equal(oldStatus, 401); | |
| 37 | + | assert.equal(await page.locator('#view-description').textContent(), 'Connected browser terminal views: unknown'); | |
| 38 | + | await page.locator('#reconnect').click(); | |
| 39 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Authentication required'); | |
| 40 | + | assert.equal(await page.locator('#help').isVisible(), true); | |
| 41 | + | assert.equal(await page.locator('#reconnect').isVisible(), false); | |
| 42 | + | const revoked = await browser.newPage(); | |
| 43 | + | await revoked.goto(first); | |
| 44 | + | await revoked.waitForFunction(() => document.querySelector('#status').textContent === 'Authentication required'); | |
| 45 | + | await revoked.goto(next); | |
| 46 | + | await revoked.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |
| 47 | + | assert.equal(await revoked.locator('#help').isVisible(), false); | |
| 48 | + | await revoked.close(); | |
| 49 | + | await page.goto(next); | |
| 50 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |
| 51 | + | console.log('Web access smoke passed: stable live token, view count, rotation, cookie revocation, reconnect.'); | |
| 52 | + | } finally { await browser.close(); } | |
| 53 | + | })().catch(error => { console.error(error); process.exitCode = 1; }); | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | // Isolated profile only: rotates access and disconnects all browser views. | |||
| 2 | + | const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); | |||
| 3 | + | const { execFileSync } = require('node:child_process'); | |||
| 4 | + | const assert = require('node:assert/strict'); | |||
| 5 | + | (async () => { | |||
| 6 | + | if (!process.env.VEV_BINARY || !process.env.VEV_ENV) throw new Error('VEV_BINARY and isolated VEV_ENV are required'); | |||
| 7 | + | const link = flag => execFileSync(process.env.VEV_BINARY, [flag], { encoding: 'utf8' }).match(/http:\/\/127\.0\.0\.1:8778\/#token=[A-Za-z0-9_-]+/)[0]; | |||
| 8 | + | const first = link('--web-daemon'); | |||
| 9 | + | assert.equal(link('--web-daemon'), first); | |||
| 10 | + | const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH }); | |||
| 11 | + | try { | |||
| 12 | + | const page = await browser.newPage(); | |||
| 13 | + | await page.goto(first); | |||
| 14 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |||
| 15 | + | await page.waitForFunction(() => Number(document.querySelector('#view-count').textContent) >= 1); | |||
| 16 | + | const initial = Number(await page.locator('#view-count').textContent()); | |||
| 17 | + | assert.equal(await page.locator('#view-description').textContent(), `Connected browser terminal views: ${initial}`); | |||
| 18 | + | const button = page.locator('#palette'); | |||
| 19 | + | const normal = await button.evaluate(node => ({ color: getComputedStyle(node).color, background: getComputedStyle(node).backgroundColor, border: getComputedStyle(node).borderTopWidth })); | |||
| 20 | + | await button.hover(); | |||
| 21 | + | const hover = await button.evaluate(node => ({ color: getComputedStyle(node).color, background: getComputedStyle(node).backgroundColor })); | |||
| 22 | + | assert.equal(normal.border, '0px'); | |||
| 23 | + | assert.equal(hover.background, normal.background); | |||
| 24 | + | assert.notEqual(hover.color, normal.color); | |||
| 25 | + | const other = await browser.newPage(); | |||
| 26 | + | await other.goto(first); | |||
| 27 | + | await other.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |||
| 28 | + | await page.waitForFunction(n => Number(document.querySelector('#view-count').textContent) === n + 1, initial); | |||
| 29 | + | assert.equal(await page.locator('#connection').getAttribute('data-state'), 'connected'); | |||
| 30 | + | await other.close(); | |||
| 31 | + | await page.waitForFunction(n => Number(document.querySelector('#view-count').textContent) === n, initial); | |||
| 32 | + | const next = link('--web-renew-token'); | |||
| 33 | + | assert.notEqual(next, first); | |||
| 34 | + | await page.waitForFunction(() => document.querySelector('#status').textContent.startsWith('Disconnected')); | |||
| 35 | + | const oldStatus = await page.evaluate(async () => (await fetch('/health')).status); | |||
| 36 | + | assert.equal(oldStatus, 401); | |||
| 37 | + | assert.equal(await page.locator('#view-description').textContent(), 'Connected browser terminal views: unknown'); | |||
| 38 | + | await page.locator('#reconnect').click(); | |||
| 39 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Authentication required'); | |||
| 40 | + | assert.equal(await page.locator('#help').isVisible(), true); | |||
| 41 | + | assert.equal(await page.locator('#reconnect').isVisible(), false); | |||
| 42 | + | const revoked = await browser.newPage(); | |||
| 43 | + | await revoked.goto(first); | |||
| 44 | + | await revoked.waitForFunction(() => document.querySelector('#status').textContent === 'Authentication required'); | |||
| 45 | + | await revoked.goto(next); | |||
| 46 | + | await revoked.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |||
| 47 | + | assert.equal(await revoked.locator('#help').isVisible(), false); | |||
| 48 | + | await revoked.close(); | |||
| 49 | + | await page.goto(next); | |||
| 50 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |||
| 51 | + | console.log('Web access smoke passed: stable live token, view count, rotation, cookie revocation, reconnect.'); | |||
| 52 | + | } finally { await browser.close(); } | |||
| 53 | + | })().catch(error => { console.error(error); process.exitCode = 1; }); | |||
A scripts/web-latency.cjs +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | // Isolated gateway only; creates a retained ephemeral session per viewport. | |
| 2 | + | const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); | |
| 3 | + | const { execFileSync } = require('node:child_process'); | |
| 4 | + | (async () => { | |
| 5 | + | if (!process.env.VEV_BINARY || !process.env.VEV_ENV) throw new Error('VEV_BINARY and isolated VEV_ENV are required'); | |
| 6 | + | const output = execFileSync(process.env.VEV_BINARY, ['--web-daemon'], { encoding: 'utf8' }); | |
| 7 | + | const token = output.match(/#token=([A-Za-z0-9_-]+)/)[1]; | |
| 8 | + | const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH }); | |
| 9 | + | try { | |
| 10 | + | for (const viewport of [{width:800,height:500}, {width:1600,height:1000}, {width:2560,height:1440}, {width:3840,height:2160}]) { | |
| 11 | + | const page = await browser.newPage({viewport}); | |
| 12 | + | if (process.env.WEB_APP_JS) await page.route('**/app.js', route => route.fulfill({path:process.env.WEB_APP_JS,contentType:'text/javascript'})); | |
| 13 | + | if (process.env.WEB_RUNTIME_JS) await page.route('**/terminal.js', route => route.fulfill({path:process.env.WEB_RUNTIME_JS,contentType:'text/javascript'})); | |
| 14 | + | await page.addInitScript(() => { | |
| 15 | + | const NativeWebSocket = window.WebSocket; | |
| 16 | + | window.wire = []; | |
| 17 | + | window.WebSocket = class extends NativeWebSocket { | |
| 18 | + | constructor(...args) { | |
| 19 | + | super(...args); | |
| 20 | + | this.addEventListener('message', event => { | |
| 21 | + | const entry = {received:performance.now(), bytes:event.data.length}; | |
| 22 | + | window.wire.push(entry); | |
| 23 | + | queueMicrotask(() => { entry.applied = performance.now(); }); | |
| 24 | + | }); | |
| 25 | + | } | |
| 26 | + | }; | |
| 27 | + | }); | |
| 28 | + | await page.goto(`http://127.0.0.1:8778/#token=${token}`); | |
| 29 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |
| 30 | + | await page.waitForTimeout(1000); | |
| 31 | + | if (process.env.WEB_LAYER) await page.evaluate(() => { | |
| 32 | + | for (const rule of ['#terminal .vev-terminal__cell { overflow: visible; }', '#terminal .vev-terminal__row { transform: translateZ(0); }']) document.styleSheets[0].insertRule(rule, document.styleSheets[0].cssRules.length); | |
| 33 | + | }); | |
| 34 | + | const cdp = await page.context().newCDPSession(page); | |
| 35 | + | await cdp.send('Performance.enable'); | |
| 36 | + | const trace = []; | |
| 37 | + | if (process.env.WEB_PROFILE && viewport.width === 3840) { | |
| 38 | + | cdp.on('Tracing.dataCollected', ({value}) => trace.push(...value)); | |
| 39 | + | await cdp.send('Tracing.start', {categories:'devtools.timeline', transferMode:'ReportEvents'}); | |
| 40 | + | await cdp.send('Profiler.enable'); | |
| 41 | + | await cdp.send('Profiler.start'); | |
| 42 | + | } | |
| 43 | + | const metricsBefore = await cdp.send('Performance.getMetrics'); | |
| 44 | + | const samples = []; | |
| 45 | + | let expected = ''; | |
| 46 | + | for (const key of 'abcdefghij') { | |
| 47 | + | expected += key; | |
| 48 | + | await page.evaluate(({key, expected}) => { | |
| 49 | + | window.sample = new Promise(resolve => { | |
| 50 | + | const output = document.querySelector('.vev-terminal__accessible-output'); | |
| 51 | + | const before = output.textContent; | |
| 52 | + | const start = performance.now(); | |
| 53 | + | const observer = new MutationObserver(() => { | |
| 54 | + | if (output.textContent !== before && output.textContent.includes(expected)) { | |
| 55 | + | observer.disconnect(); | |
| 56 | + | const last = window.wire.at(-1); | |
| 57 | + | resolve({total:performance.now()-start, receive:last.received-start, apply:performance.now()-last.received, bytes:last.bytes}); | |
| 58 | + | } | |
| 59 | + | }); | |
| 60 | + | observer.observe(output, {childList:true,subtree:true,characterData:true}); | |
| 61 | + | document.querySelector('textarea').dispatchEvent(new InputEvent('beforeinput', {data:key,inputType:'insertText',bubbles:true,cancelable:true})); | |
| 62 | + | }); | |
| 63 | + | }, {key, expected}); | |
| 64 | + | samples.push(await page.evaluate(() => Promise.race([window.sample, new Promise((_, reject) => setTimeout(() => reject(new Error('input echo timeout')), 5000))]))); | |
| 65 | + | } | |
| 66 | + | if (process.env.WEB_PROFILE && viewport.width === 3840) { | |
| 67 | + | const completed = new Promise(resolve => cdp.once('Tracing.tracingComplete', resolve)); | |
| 68 | + | await cdp.send('Tracing.end'); | |
| 69 | + | await completed; | |
| 70 | + | const durations = {}; | |
| 71 | + | for (const event of trace) if (event.ph === 'X' && event.dur) durations[event.name] = (durations[event.name] || 0) + event.dur / 1000; | |
| 72 | + | console.log('Trace ms', Object.entries(durations).sort((a,b)=>b[1]-a[1]).slice(0,20)); | |
| 73 | + | const {profile} = await cdp.send('Profiler.stop'); | |
| 74 | + | const counts = new Map(); | |
| 75 | + | for (const id of profile.samples || []) counts.set(id, (counts.get(id) || 0)+1); | |
| 76 | + | console.log('CPU samples', profile.nodes.map(node => ({name:node.callFrame.functionName, url:node.callFrame.url, count:counts.get(node.id)||0})).sort((a,b)=>b.count-a.count).slice(0,15)); | |
| 77 | + | } | |
| 78 | + | const metricsAfter = await cdp.send('Performance.getMetrics'); | |
| 79 | + | const costs = {}; | |
| 80 | + | for (const metric of metricsAfter.metrics) { | |
| 81 | + | if (['LayoutDuration','RecalcStyleDuration','ScriptDuration','TaskDuration','LayoutCount','RecalcStyleCount'].includes(metric.name)) { | |
| 82 | + | costs[metric.name] = metric.value - metricsBefore.metrics.find(before => before.name === metric.name).value; | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | console.log(JSON.stringify({viewport, samplesMs:samples, costs})); | |
| 86 | + | await page.keyboard.press('Control+c'); | |
| 87 | + | const action = async (name, text) => { | |
| 88 | + | await page.evaluate(({name, text}) => { | |
| 89 | + | window.actionSample = new Promise(resolve => { | |
| 90 | + | const output = document.querySelector('.vev-terminal__accessible-output'); | |
| 91 | + | const start = performance.now(); | |
| 92 | + | const observer = new MutationObserver(() => { | |
| 93 | + | if (output.textContent.includes(text)) { | |
| 94 | + | observer.disconnect(); | |
| 95 | + | const last = window.wire.at(-1); | |
| 96 | + | resolve({total:performance.now()-start,receive:last.received-start,apply:performance.now()-last.received,bytes:last.bytes}); | |
| 97 | + | } | |
| 98 | + | }); | |
| 99 | + | observer.observe(output, {childList:true,subtree:true}); | |
| 100 | + | if (name === 'palette') document.querySelector('#palette').click(); | |
| 101 | + | else document.querySelector('textarea').dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',code:'Enter',bubbles:true,cancelable:true})); | |
| 102 | + | }); | |
| 103 | + | }, {name, text}); | |
| 104 | + | const ms = await page.evaluate(() => Promise.race([window.actionSample, new Promise((_, reject) => setTimeout(() => reject(new Error('action timeout')), 5000))])); | |
| 105 | + | console.log(JSON.stringify({viewport, action:name, ms})); | |
| 106 | + | }; | |
| 107 | + | await action('palette', 'Commands'); | |
| 108 | + | await page.keyboard.type('SPR'); | |
| 109 | + | await page.waitForTimeout(300); | |
| 110 | + | await action('split', '│'); | |
| 111 | + | await page.close(); | |
| 112 | + | } | |
| 113 | + | } finally { await browser.close(); } | |
| 114 | + | })().catch(error => { console.error(error); process.exitCode=1; }); | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | // Isolated gateway only; creates a retained ephemeral session per viewport. | |||
| 2 | + | const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); | |||
| 3 | + | const { execFileSync } = require('node:child_process'); | |||
| 4 | + | (async () => { | |||
| 5 | + | if (!process.env.VEV_BINARY || !process.env.VEV_ENV) throw new Error('VEV_BINARY and isolated VEV_ENV are required'); | |||
| 6 | + | const output = execFileSync(process.env.VEV_BINARY, ['--web-daemon'], { encoding: 'utf8' }); | |||
| 7 | + | const token = output.match(/#token=([A-Za-z0-9_-]+)/)[1]; | |||
| 8 | + | const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH }); | |||
| 9 | + | try { | |||
| 10 | + | for (const viewport of [{width:800,height:500}, {width:1600,height:1000}, {width:2560,height:1440}, {width:3840,height:2160}]) { | |||
| 11 | + | const page = await browser.newPage({viewport}); | |||
| 12 | + | if (process.env.WEB_APP_JS) await page.route('**/app.js', route => route.fulfill({path:process.env.WEB_APP_JS,contentType:'text/javascript'})); | |||
| 13 | + | if (process.env.WEB_RUNTIME_JS) await page.route('**/terminal.js', route => route.fulfill({path:process.env.WEB_RUNTIME_JS,contentType:'text/javascript'})); | |||
| 14 | + | await page.addInitScript(() => { | |||
| 15 | + | const NativeWebSocket = window.WebSocket; | |||
| 16 | + | window.wire = []; | |||
| 17 | + | window.WebSocket = class extends NativeWebSocket { | |||
| 18 | + | constructor(...args) { | |||
| 19 | + | super(...args); | |||
| 20 | + | this.addEventListener('message', event => { | |||
| 21 | + | const entry = {received:performance.now(), bytes:event.data.length}; | |||
| 22 | + | window.wire.push(entry); | |||
| 23 | + | queueMicrotask(() => { entry.applied = performance.now(); }); | |||
| 24 | + | }); | |||
| 25 | + | } | |||
| 26 | + | }; | |||
| 27 | + | }); | |||
| 28 | + | await page.goto(`http://127.0.0.1:8778/#token=${token}`); | |||
| 29 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |||
| 30 | + | await page.waitForTimeout(1000); | |||
| 31 | + | if (process.env.WEB_LAYER) await page.evaluate(() => { | |||
| 32 | + | for (const rule of ['#terminal .vev-terminal__cell { overflow: visible; }', '#terminal .vev-terminal__row { transform: translateZ(0); }']) document.styleSheets[0].insertRule(rule, document.styleSheets[0].cssRules.length); | |||
| 33 | + | }); | |||
| 34 | + | const cdp = await page.context().newCDPSession(page); | |||
| 35 | + | await cdp.send('Performance.enable'); | |||
| 36 | + | const trace = []; | |||
| 37 | + | if (process.env.WEB_PROFILE && viewport.width === 3840) { | |||
| 38 | + | cdp.on('Tracing.dataCollected', ({value}) => trace.push(...value)); | |||
| 39 | + | await cdp.send('Tracing.start', {categories:'devtools.timeline', transferMode:'ReportEvents'}); | |||
| 40 | + | await cdp.send('Profiler.enable'); | |||
| 41 | + | await cdp.send('Profiler.start'); | |||
| 42 | + | } | |||
| 43 | + | const metricsBefore = await cdp.send('Performance.getMetrics'); | |||
| 44 | + | const samples = []; | |||
| 45 | + | let expected = ''; | |||
| 46 | + | for (const key of 'abcdefghij') { | |||
| 47 | + | expected += key; | |||
| 48 | + | await page.evaluate(({key, expected}) => { | |||
| 49 | + | window.sample = new Promise(resolve => { | |||
| 50 | + | const output = document.querySelector('.vev-terminal__accessible-output'); | |||
| 51 | + | const before = output.textContent; | |||
| 52 | + | const start = performance.now(); | |||
| 53 | + | const observer = new MutationObserver(() => { | |||
| 54 | + | if (output.textContent !== before && output.textContent.includes(expected)) { | |||
| 55 | + | observer.disconnect(); | |||
| 56 | + | const last = window.wire.at(-1); | |||
| 57 | + | resolve({total:performance.now()-start, receive:last.received-start, apply:performance.now()-last.received, bytes:last.bytes}); | |||
| 58 | + | } | |||
| 59 | + | }); | |||
| 60 | + | observer.observe(output, {childList:true,subtree:true,characterData:true}); | |||
| 61 | + | document.querySelector('textarea').dispatchEvent(new InputEvent('beforeinput', {data:key,inputType:'insertText',bubbles:true,cancelable:true})); | |||
| 62 | + | }); | |||
| 63 | + | }, {key, expected}); | |||
| 64 | + | samples.push(await page.evaluate(() => Promise.race([window.sample, new Promise((_, reject) => setTimeout(() => reject(new Error('input echo timeout')), 5000))]))); | |||
| 65 | + | } | |||
| 66 | + | if (process.env.WEB_PROFILE && viewport.width === 3840) { | |||
| 67 | + | const completed = new Promise(resolve => cdp.once('Tracing.tracingComplete', resolve)); | |||
| 68 | + | await cdp.send('Tracing.end'); | |||
| 69 | + | await completed; | |||
| 70 | + | const durations = {}; | |||
| 71 | + | for (const event of trace) if (event.ph === 'X' && event.dur) durations[event.name] = (durations[event.name] || 0) + event.dur / 1000; | |||
| 72 | + | console.log('Trace ms', Object.entries(durations).sort((a,b)=>b[1]-a[1]).slice(0,20)); | |||
| 73 | + | const {profile} = await cdp.send('Profiler.stop'); | |||
| 74 | + | const counts = new Map(); | |||
| 75 | + | for (const id of profile.samples || []) counts.set(id, (counts.get(id) || 0)+1); | |||
| 76 | + | console.log('CPU samples', profile.nodes.map(node => ({name:node.callFrame.functionName, url:node.callFrame.url, count:counts.get(node.id)||0})).sort((a,b)=>b.count-a.count).slice(0,15)); | |||
| 77 | + | } | |||
| 78 | + | const metricsAfter = await cdp.send('Performance.getMetrics'); | |||
| 79 | + | const costs = {}; | |||
| 80 | + | for (const metric of metricsAfter.metrics) { | |||
| 81 | + | if (['LayoutDuration','RecalcStyleDuration','ScriptDuration','TaskDuration','LayoutCount','RecalcStyleCount'].includes(metric.name)) { | |||
| 82 | + | costs[metric.name] = metric.value - metricsBefore.metrics.find(before => before.name === metric.name).value; | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | console.log(JSON.stringify({viewport, samplesMs:samples, costs})); | |||
| 86 | + | await page.keyboard.press('Control+c'); | |||
| 87 | + | const action = async (name, text) => { | |||
| 88 | + | await page.evaluate(({name, text}) => { | |||
| 89 | + | window.actionSample = new Promise(resolve => { | |||
| 90 | + | const output = document.querySelector('.vev-terminal__accessible-output'); | |||
| 91 | + | const start = performance.now(); | |||
| 92 | + | const observer = new MutationObserver(() => { | |||
| 93 | + | if (output.textContent.includes(text)) { | |||
| 94 | + | observer.disconnect(); | |||
| 95 | + | const last = window.wire.at(-1); | |||
| 96 | + | resolve({total:performance.now()-start,receive:last.received-start,apply:performance.now()-last.received,bytes:last.bytes}); | |||
| 97 | + | } | |||
| 98 | + | }); | |||
| 99 | + | observer.observe(output, {childList:true,subtree:true}); | |||
| 100 | + | if (name === 'palette') document.querySelector('#palette').click(); | |||
| 101 | + | else document.querySelector('textarea').dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',code:'Enter',bubbles:true,cancelable:true})); | |||
| 102 | + | }); | |||
| 103 | + | }, {name, text}); | |||
| 104 | + | const ms = await page.evaluate(() => Promise.race([window.actionSample, new Promise((_, reject) => setTimeout(() => reject(new Error('action timeout')), 5000))])); | |||
| 105 | + | console.log(JSON.stringify({viewport, action:name, ms})); | |||
| 106 | + | }; | |||
| 107 | + | await action('palette', 'Commands'); | |||
| 108 | + | await page.keyboard.type('SPR'); | |||
| 109 | + | await page.waitForTimeout(300); | |||
| 110 | + | await action('split', '│'); | |||
| 111 | + | await page.close(); | |||
| 112 | + | } | |||
| 113 | + | } finally { await browser.close(); } | |||
| 114 | + | })().catch(error => { console.error(error); process.exitCode=1; }); | |||
A scripts/web-mobile-smoke.cjs +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | // Isolated profile only. Device emulation does not emulate a real soft keyboard. | |
| 2 | + | const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); | |
| 3 | + | const { execFileSync } = require('node:child_process'); | |
| 4 | + | const assert = require('node:assert/strict'); | |
| 5 | + | (async () => { | |
| 6 | + | if (!process.env.VEV_BINARY || !process.env.VEV_ENV) throw new Error('VEV_BINARY and isolated VEV_ENV are required'); | |
| 7 | + | const link = execFileSync(process.env.VEV_BINARY, ['--web-daemon'], { encoding: 'utf8' }).match(/http:\/\/127\.0\.0\.1:8778\/#token=[A-Za-z0-9_-]+/)[0]; | |
| 8 | + | const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH }); | |
| 9 | + | try { | |
| 10 | + | const page = await browser.newPage({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); | |
| 11 | + | await page.goto(link); | |
| 12 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |
| 13 | + | assert.equal(await page.locator('#select-mode').getAttribute('aria-pressed'), 'true'); | |
| 14 | + | assert.equal(await page.evaluate(() => document.activeElement.tagName === 'TEXTAREA'), false); | |
| 15 | + | for (const id of ['keyboard', 'select-mode', 'palette']) { | |
| 16 | + | const bounds = await page.locator('#' + id).boundingBox(); | |
| 17 | + | assert.ok(bounds.width >= 44 && bounds.height >= 44); | |
| 18 | + | } | |
| 19 | + | await page.locator('#keyboard').tap(); | |
| 20 | + | assert.equal(await page.evaluate(() => document.activeElement.tagName), 'TEXTAREA'); | |
| 21 | + | assert.equal(await page.locator('#select-mode').getAttribute('aria-pressed'), 'false'); | |
| 22 | + | await page.locator('#select-mode').tap(); | |
| 23 | + | assert.equal(await page.evaluate(() => document.activeElement.tagName === 'TEXTAREA'), false); | |
| 24 | + | await page.setViewportSize({ width: 390, height: 400 }); | |
| 25 | + | await page.waitForFunction(() => document.querySelector('#workspace').getBoundingClientRect().bottom <= window.innerHeight + 1); | |
| 26 | + | assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true); | |
| 27 | + | console.log('Mobile emulation passed: touch targets, explicit keyboard, selection mode, compact viewport.'); | |
| 28 | + | } finally { await browser.close(); } | |
| 29 | + | })().catch(error => { console.error(error); process.exitCode = 1; }); | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | // Isolated profile only. Device emulation does not emulate a real soft keyboard. | |||
| 2 | + | const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); | |||
| 3 | + | const { execFileSync } = require('node:child_process'); | |||
| 4 | + | const assert = require('node:assert/strict'); | |||
| 5 | + | (async () => { | |||
| 6 | + | if (!process.env.VEV_BINARY || !process.env.VEV_ENV) throw new Error('VEV_BINARY and isolated VEV_ENV are required'); | |||
| 7 | + | const link = execFileSync(process.env.VEV_BINARY, ['--web-daemon'], { encoding: 'utf8' }).match(/http:\/\/127\.0\.0\.1:8778\/#token=[A-Za-z0-9_-]+/)[0]; | |||
| 8 | + | const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH }); | |||
| 9 | + | try { | |||
| 10 | + | const page = await browser.newPage({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); | |||
| 11 | + | await page.goto(link); | |||
| 12 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |||
| 13 | + | assert.equal(await page.locator('#select-mode').getAttribute('aria-pressed'), 'true'); | |||
| 14 | + | assert.equal(await page.evaluate(() => document.activeElement.tagName === 'TEXTAREA'), false); | |||
| 15 | + | for (const id of ['keyboard', 'select-mode', 'palette']) { | |||
| 16 | + | const bounds = await page.locator('#' + id).boundingBox(); | |||
| 17 | + | assert.ok(bounds.width >= 44 && bounds.height >= 44); | |||
| 18 | + | } | |||
| 19 | + | await page.locator('#keyboard').tap(); | |||
| 20 | + | assert.equal(await page.evaluate(() => document.activeElement.tagName), 'TEXTAREA'); | |||
| 21 | + | assert.equal(await page.locator('#select-mode').getAttribute('aria-pressed'), 'false'); | |||
| 22 | + | await page.locator('#select-mode').tap(); | |||
| 23 | + | assert.equal(await page.evaluate(() => document.activeElement.tagName === 'TEXTAREA'), false); | |||
| 24 | + | await page.setViewportSize({ width: 390, height: 400 }); | |||
| 25 | + | await page.waitForFunction(() => document.querySelector('#workspace').getBoundingClientRect().bottom <= window.innerHeight + 1); | |||
| 26 | + | assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true); | |||
| 27 | + | console.log('Mobile emulation passed: touch targets, explicit keyboard, selection mode, compact viewport.'); | |||
| 28 | + | } finally { await browser.close(); } | |||
| 29 | + | })().catch(error => { console.error(error); process.exitCode = 1; }); | |||
A scripts/web-visual-smoke.cjs +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | // Run against an isolated gateway. Supply PLAYWRIGHT_MODULE, VEV_BINARY and VEV_ENV. | |
| 2 | + | // The test creates sessions/panes; it never targets an existing named session. | |
| 3 | + | const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); | |
| 4 | + | const { execFileSync } = require('node:child_process'); | |
| 5 | + | const assert = require('node:assert/strict'); | |
| 6 | + | ||
| 7 | + | (async () => { | |
| 8 | + | if (!process.env.VEV_BINARY || !process.env.VEV_ENV) throw new Error('VEV_BINARY and isolated VEV_ENV are required'); | |
| 9 | + | const browser = await chromium.launch({ | |
| 10 | + | executablePath: process.env.CHROMIUM_PATH || undefined, | |
| 11 | + | headless: true | |
| 12 | + | }); | |
| 13 | + | try { | |
| 14 | + | const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); | |
| 15 | + | const errors = []; | |
| 16 | + | page.on('pageerror', error => errors.push(error.message)); | |
| 17 | + | const output = execFileSync(process.env.VEV_BINARY, ['--web-daemon'], { encoding: 'utf8' }); | |
| 18 | + | const token = output.match(/#token=([A-Za-z0-9_-]+)/)[1]; | |
| 19 | + | await page.goto(`http://127.0.0.1:8778/#token=${token}`); | |
| 20 | + | const connected = () => page.waitForFunction(() => document.querySelector('#status').textContent.startsWith('Connected')); | |
| 21 | + | const contains = text => page.waitForFunction(text => document.querySelector('.vev-terminal__accessible-output').textContent.includes(text), text); | |
| 22 | + | await connected(); | |
| 23 | + | assert.equal(new URL(page.url()).hash, ''); | |
| 24 | + | await page.keyboard.type("printf '\\033[2J\\033[HDIMCHECK\\n'"); | |
| 25 | + | await page.keyboard.press('Enter'); | |
| 26 | + | await contains('DIMCHECK'); | |
| 27 | + | const markerColor = () => page.evaluate(() => { | |
| 28 | + | const cell = [...document.querySelectorAll('.vev-terminal__cell')].find(cell => cell.textContent === 'D'); | |
| 29 | + | return cell && getComputedStyle(cell).color; | |
| 30 | + | }); | |
| 31 | + | const normalColor = await markerColor(); | |
| 32 | + | assert.ok(normalColor); | |
| 33 | + | await page.locator('#palette').click(); | |
| 34 | + | await contains('Commands'); | |
| 35 | + | assert.notEqual(await markerColor(), normalColor, 'palette backdrop uses theme dimming'); | |
| 36 | + | await page.keyboard.press('Escape'); | |
| 37 | + | await page.waitForFunction(() => !document.querySelector('.vev-terminal__accessible-output').textContent.includes('Commands')); | |
| 38 | + | assert.equal(await markerColor(), normalColor, 'closing the palette restores the theme'); | |
| 39 | + | await page.locator('#palette').click(); | |
| 40 | + | await contains('Commands'); | |
| 41 | + | await page.keyboard.type('SPR'); | |
| 42 | + | await page.keyboard.press('Enter'); | |
| 43 | + | await contains('│'); | |
| 44 | + | await page.keyboard.type("printf '\\033[2J\\033[HRIGHT-PANE\\n'"); | |
| 45 | + | await page.keyboard.press('Enter'); | |
| 46 | + | await contains('RIGHT-PANE'); | |
| 47 | + | await page.keyboard.press('Alt+h'); | |
| 48 | + | await page.keyboard.type("printf '\\033[2J\\033[HLEFT-PANE\\n'"); | |
| 49 | + | await page.keyboard.press('Enter'); | |
| 50 | + | await contains('LEFT-PANE'); | |
| 51 | + | const bounds = await page.locator('#terminal').boundingBox(); | |
| 52 | + | await page.mouse.click(bounds.x + bounds.width * .75, bounds.y + 90); | |
| 53 | + | await page.keyboard.type('MOUSE-CHECK'); | |
| 54 | + | await contains('MOUSE-CHECK'); | |
| 55 | + | await page.keyboard.press('Control+c'); | |
| 56 | + | await page.setViewportSize({ width: 960, height: 600 }); | |
| 57 | + | await page.waitForFunction(() => document.querySelectorAll('.vev-terminal__row').length < 40); | |
| 58 | + | await page.locator('#terminal').evaluate(root => { | |
| 59 | + | const clipboardData = new DataTransfer(); | |
| 60 | + | clipboardData.setData('text/plain', 'paste'); | |
| 61 | + | root.querySelector('textarea').dispatchEvent(new ClipboardEvent('paste', { clipboardData, bubbles: true, cancelable: true })); | |
| 62 | + | }); | |
| 63 | + | await page.waitForFunction(() => { | |
| 64 | + | const rows = [...document.querySelectorAll('.vev-terminal__row')]; | |
| 65 | + | const right = rows.map(row => { | |
| 66 | + | const cells = [...row.children]; | |
| 67 | + | const divider = cells.findIndex(cell => cell.textContent === '│'); | |
| 68 | + | return cells.slice(divider + 1).map(cell => cell.textContent).join('').trimEnd(); | |
| 69 | + | }).join(''); | |
| 70 | + | return right.includes('paste'); | |
| 71 | + | }); | |
| 72 | + | await page.keyboard.press('Control+c'); | |
| 73 | + | await page.reload(); | |
| 74 | + | await connected(); | |
| 75 | + | assert.deepEqual(errors, []); | |
| 76 | + | console.log('Web visual smoke passed: auth, palette, split, keyboard, mouse input, resize, paste, reload.'); | |
| 77 | + | } finally { | |
| 78 | + | await browser.close(); | |
| 79 | + | } | |
| 80 | + | })().catch(error => { console.error(error); process.exitCode = 1; }); | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | // Run against an isolated gateway. Supply PLAYWRIGHT_MODULE, VEV_BINARY and VEV_ENV. | |||
| 2 | + | // The test creates sessions/panes; it never targets an existing named session. | |||
| 3 | + | const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); | |||
| 4 | + | const { execFileSync } = require('node:child_process'); | |||
| 5 | + | const assert = require('node:assert/strict'); | |||
| 6 | + | ||||
| 7 | + | (async () => { | |||
| 8 | + | if (!process.env.VEV_BINARY || !process.env.VEV_ENV) throw new Error('VEV_BINARY and isolated VEV_ENV are required'); | |||
| 9 | + | const browser = await chromium.launch({ | |||
| 10 | + | executablePath: process.env.CHROMIUM_PATH || undefined, | |||
| 11 | + | headless: true | |||
| 12 | + | }); | |||
| 13 | + | try { | |||
| 14 | + | const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); | |||
| 15 | + | const errors = []; | |||
| 16 | + | page.on('pageerror', error => errors.push(error.message)); | |||
| 17 | + | const output = execFileSync(process.env.VEV_BINARY, ['--web-daemon'], { encoding: 'utf8' }); | |||
| 18 | + | const token = output.match(/#token=([A-Za-z0-9_-]+)/)[1]; | |||
| 19 | + | await page.goto(`http://127.0.0.1:8778/#token=${token}`); | |||
| 20 | + | const connected = () => page.waitForFunction(() => document.querySelector('#status').textContent.startsWith('Connected')); | |||
| 21 | + | const contains = text => page.waitForFunction(text => document.querySelector('.vev-terminal__accessible-output').textContent.includes(text), text); | |||
| 22 | + | await connected(); | |||
| 23 | + | assert.equal(new URL(page.url()).hash, ''); | |||
| 24 | + | await page.keyboard.type("printf '\\033[2J\\033[HDIMCHECK\\n'"); | |||
| 25 | + | await page.keyboard.press('Enter'); | |||
| 26 | + | await contains('DIMCHECK'); | |||
| 27 | + | const markerColor = () => page.evaluate(() => { | |||
| 28 | + | const cell = [...document.querySelectorAll('.vev-terminal__cell')].find(cell => cell.textContent === 'D'); | |||
| 29 | + | return cell && getComputedStyle(cell).color; | |||
| 30 | + | }); | |||
| 31 | + | const normalColor = await markerColor(); | |||
| 32 | + | assert.ok(normalColor); | |||
| 33 | + | await page.locator('#palette').click(); | |||
| 34 | + | await contains('Commands'); | |||
| 35 | + | assert.notEqual(await markerColor(), normalColor, 'palette backdrop uses theme dimming'); | |||
| 36 | + | await page.keyboard.press('Escape'); | |||
| 37 | + | await page.waitForFunction(() => !document.querySelector('.vev-terminal__accessible-output').textContent.includes('Commands')); | |||
| 38 | + | assert.equal(await markerColor(), normalColor, 'closing the palette restores the theme'); | |||
| 39 | + | await page.locator('#palette').click(); | |||
| 40 | + | await contains('Commands'); | |||
| 41 | + | await page.keyboard.type('SPR'); | |||
| 42 | + | await page.keyboard.press('Enter'); | |||
| 43 | + | await contains('│'); | |||
| 44 | + | await page.keyboard.type("printf '\\033[2J\\033[HRIGHT-PANE\\n'"); | |||
| 45 | + | await page.keyboard.press('Enter'); | |||
| 46 | + | await contains('RIGHT-PANE'); | |||
| 47 | + | await page.keyboard.press('Alt+h'); | |||
| 48 | + | await page.keyboard.type("printf '\\033[2J\\033[HLEFT-PANE\\n'"); | |||
| 49 | + | await page.keyboard.press('Enter'); | |||
| 50 | + | await contains('LEFT-PANE'); | |||
| 51 | + | const bounds = await page.locator('#terminal').boundingBox(); | |||
| 52 | + | await page.mouse.click(bounds.x + bounds.width * .75, bounds.y + 90); | |||
| 53 | + | await page.keyboard.type('MOUSE-CHECK'); | |||
| 54 | + | await contains('MOUSE-CHECK'); | |||
| 55 | + | await page.keyboard.press('Control+c'); | |||
| 56 | + | await page.setViewportSize({ width: 960, height: 600 }); | |||
| 57 | + | await page.waitForFunction(() => document.querySelectorAll('.vev-terminal__row').length < 40); | |||
| 58 | + | await page.locator('#terminal').evaluate(root => { | |||
| 59 | + | const clipboardData = new DataTransfer(); | |||
| 60 | + | clipboardData.setData('text/plain', 'paste'); | |||
| 61 | + | root.querySelector('textarea').dispatchEvent(new ClipboardEvent('paste', { clipboardData, bubbles: true, cancelable: true })); | |||
| 62 | + | }); | |||
| 63 | + | await page.waitForFunction(() => { | |||
| 64 | + | const rows = [...document.querySelectorAll('.vev-terminal__row')]; | |||
| 65 | + | const right = rows.map(row => { | |||
| 66 | + | const cells = [...row.children]; | |||
| 67 | + | const divider = cells.findIndex(cell => cell.textContent === '│'); | |||
| 68 | + | return cells.slice(divider + 1).map(cell => cell.textContent).join('').trimEnd(); | |||
| 69 | + | }).join(''); | |||
| 70 | + | return right.includes('paste'); | |||
| 71 | + | }); | |||
| 72 | + | await page.keyboard.press('Control+c'); | |||
| 73 | + | await page.reload(); | |||
| 74 | + | await connected(); | |||
| 75 | + | assert.deepEqual(errors, []); | |||
| 76 | + | console.log('Web visual smoke passed: auth, palette, split, keyboard, mouse input, resize, paste, reload.'); | |||
| 77 | + | } finally { | |||
| 78 | + | await browser.close(); | |||
| 79 | + | } | |||
| 80 | + | })().catch(error => { console.error(error); process.exitCode = 1; }); | |||